gitoriaLog in with ident

tickets

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitd3db6139d3db6139tickets: Hybriel master 190aa11d (fc838894 GC correctness, #126 closure scopes, #127); gate 249/0, connect 60/0mred3db6139/shared/md-editor.js

99.0 KB

  1. // md-editor.js — <md-editor>: a Markdown field that is edited VISUALLY (formatted while typing)
  2. // and whose value stays PLAIN MARKDOWN. worldapi-components (ticket tickets.worldapi.org#12,
  3. // concept accepted in #14). Plain JS, no build step, no dependencies, framework independent.
  4. //
  5. // <script src="md-editor.js"></script>
  6. // <md-editor><textarea name="summary" rows="4"></textarea></md-editor> ENHANCES a textarea
  7. // <md-editor name="summary" value="**hi**"></md-editor> or stands alone
  8. //
  9. // ENHANCE MODE (the normal case): the textarea stays in the form and keeps the Markdown — the
  10. // editor writes every change into it (+ an `input` event on it), and follows it when a script
  11. // sets `textarea.value` (a framework clearing the field after a send). Without JS the page shows
  12. // the plain textarea as before. STANDALONE: a form-associated element (`name`, `value`).
  13. //
  14. // THE MARKDOWN SUBSET is exactly the one tickets' renderer understands (tickets.worldapi.org
  15. // markdown.hl — the parser below is a line-by-line port of it): `#`…`######` headings, `-`/`*`/`+`
  16. // and `1.`/`1)` lists (one level), ``` / ~~~ fenced code, paragraphs whose single line breaks are
  17. // kept, `code`, [text](url), <https://…>, bare http(s) URLs, *em* _em_ **strong** __strong__
  18. // ***both***, backslash escapes. Nothing else exists in the editor (no quotes, tables, images,
  19. // nesting, HTML).
  20. //
  21. // LOSSLESS: a block the user did not change is written back byte for byte (its original source);
  22. // a changed block is written in a canonical form that is VERIFIED against the parser (it must
  23. // parse back to exactly what the editor shows, else a safer escaping/marker variant is taken).
  24. // SAFE: the editor's DOM is built only with createElement/text nodes; hrefs pass safeHref
  25. // (http(s):, mailto:, /path, #anchor); pasted HTML is parsed in an inert document and reduced to
  26. // the subset — no HTML from outside ever reaches the page.
  27. //
  28. // See README.md (attributes, events, keyboard, restyling with --md-* variables and ::part()).
  29. (() => {
  30. 'use strict';
  31. if (typeof window === 'undefined' || !window.customElements || customElements.get('md-editor')) return;
  32. const NL = '\n';
  33. // =============================================================================================
  34. // 1. THE SUBSET — port of tickets.worldapi.org/markdown.hl (keep in step with it)
  35. // =============================================================================================
  36. const isSpaceC = (c) => c === 32 || c === 9 || c === 10 || c === 13;
  37. const isAlnumC = (c) => (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c > 127;
  38. const isPunctC = (c) => (c >= 33 && c <= 47) || (c >= 58 && c <= 64) || (c >= 91 && c <= 96) || (c >= 123 && c <= 126);
  39. function safeHref(u) {
  40. if (u == null) return null;
  41. const t = String(u).trim();
  42. if (t === '' || t.length > 2000) return null;
  43. for (let i = 0; i < t.length; i++) {
  44. const c = t.charCodeAt(i);
  45. if (c <= 32 || c === 127 || c === 34 || c === 39 || c === 60 || c === 62 || c === 96 || c === 92) return null;
  46. }
  47. const low = t.toLowerCase();
  48. if (low.startsWith('https://') && t.length > 8) return t;
  49. if (low.startsWith('http://') && t.length > 7) return t;
  50. if (low.startsWith('mailto:') && t.length > 7) return t;
  51. if (t.startsWith('/') && !t.startsWith('//')) return t;
  52. if (t.startsWith('#')) return t;
  53. return null;
  54. }
  55. const isUrlStart = (s, i) => {
  56. const low = s.slice(i, i + 8).toLowerCase();
  57. return low.startsWith('https://') || low.startsWith('http://');
  58. };
  59. const isUrlStop = (c) => isSpaceC(c) || c === 60 || c === 62 || c === 34 || c === 96;
  60. function urlEnd(s, i) {
  61. let end = i;
  62. while (end < s.length && !isUrlStop(s.charCodeAt(end))) end++;
  63. let trimming = true;
  64. while (trimming && end > i) {
  65. const c = s.charCodeAt(end - 1);
  66. if (c === 46 || c === 44 || c === 59 || c === 58 || c === 33 || c === 63 || c === 39 || c === 42 || c === 95) {
  67. end--;
  68. } else if (c === 41) {
  69. let opens = 0, closes = 0;
  70. for (let k = i; k < end; k++) {
  71. if (s.charCodeAt(k) === 40) opens++;
  72. if (s.charCodeAt(k) === 41) closes++;
  73. }
  74. if (closes > opens) end--; else trimming = false;
  75. } else {
  76. trimming = false;
  77. }
  78. }
  79. return end;
  80. }
  81. function linkAt(s, i) {
  82. let j = i + 1, found = -1;
  83. while (j < s.length && found < 0) {
  84. const c = s.charCodeAt(j);
  85. if (c === 91) return null;
  86. if (c === 93) found = j;
  87. j++;
  88. }
  89. if (found < 0 || found === i + 1) return null;
  90. if (found + 1 >= s.length || s.charCodeAt(found + 1) !== 40) return null;
  91. const k = s.indexOf(')', found + 2);
  92. if (k < 0) return null;
  93. let target = s.slice(found + 2, k).trim();
  94. const sp = target.indexOf(' ');
  95. if (sp > 0) target = target.slice(0, sp);
  96. return { label: s.slice(i + 1, found), href: safeHref(target), end: k + 1 };
  97. }
  98. const textSpan = (t, strong, em) => ({ text: t, isText: !strong && !em, isStrong: strong && !em, isEm: em && !strong, isStrongEm: strong && em, isCode: false, isLink: false, href: '' });
  99. const codeSpan = (t) => ({ text: t, isText: false, isStrong: false, isEm: false, isStrongEm: false, isCode: true, isLink: false, href: '' });
  100. const linkSpan = (t, h) => ({ text: t, isText: false, isStrong: false, isEm: false, isStrongEm: false, isCode: false, isLink: true, href: h });
  101. function runAt(s, at, c, n, esc) {
  102. let i = at;
  103. while (i < s.length) {
  104. if (esc && s.charCodeAt(i) === 92) {
  105. i += 2;
  106. } else if (s.charCodeAt(i) === c) {
  107. let k = i;
  108. while (k < s.length && s.charCodeAt(k) === c) k++;
  109. if (k - i === n) return i;
  110. i = k;
  111. } else {
  112. i++;
  113. }
  114. }
  115. return -1;
  116. }
  117. function hasCloser(s, at, c, n) {
  118. let i = at;
  119. while (i < s.length) {
  120. const r = runAt(s, i, c, n, true);
  121. if (r < 0) return false;
  122. const prev = r > 0 ? s.charCodeAt(r - 1) : 32;
  123. const next = r + n < s.length ? s.charCodeAt(r + n) : 32;
  124. if (r > at && !isSpaceC(prev) && (c !== 95 || !isAlnumC(next))) return true;
  125. i = r + n;
  126. }
  127. return false;
  128. }
  129. function inlineSpans(src) {
  130. const out = [];
  131. if (src == null || src === '') return out;
  132. const s = String(src);
  133. const n = s.length;
  134. let buf = '', strong = false, em = false, strongC = 0, emC = 0, i = 0;
  135. while (i < n) {
  136. const c = s.charCodeAt(i);
  137. if (c === 92 && i + 1 < n && isPunctC(s.charCodeAt(i + 1))) {
  138. buf += s[i + 1];
  139. i += 2;
  140. } else if (c === 96) {
  141. let k = i;
  142. while (k < n && s.charCodeAt(k) === 96) k++;
  143. const close = runAt(s, k, 96, k - i, false);
  144. if (close < 0) {
  145. buf += s.slice(i, k);
  146. i = k;
  147. } else {
  148. if (buf !== '') out.push(textSpan(buf, strong, em));
  149. buf = '';
  150. let code = s.slice(k, close).split(NL).join(' ');
  151. if (code.length > 2 && code.startsWith(' ') && code.endsWith(' ') && code.trim() !== '') code = code.slice(1, code.length - 1);
  152. out.push(codeSpan(code));
  153. i = close + (k - i);
  154. }
  155. } else if (c === 91) {
  156. const l = linkAt(s, i);
  157. if (l == null) {
  158. buf += '[';
  159. i++;
  160. } else if (l.href == null) {
  161. buf += s.slice(i, l.end);
  162. i = l.end;
  163. } else {
  164. if (buf !== '') out.push(textSpan(buf, strong, em));
  165. buf = '';
  166. out.push(linkSpan(l.label, l.href));
  167. i = l.end;
  168. }
  169. } else if (c === 60 && i + 1 < n && isUrlStart(s, i + 1)) {
  170. const e = s.indexOf('>', i + 1);
  171. const url = e > 0 ? s.slice(i + 1, e) : '';
  172. const h = safeHref(url);
  173. if (e < 0 || h == null) {
  174. buf += '<';
  175. i++;
  176. } else {
  177. if (buf !== '') out.push(textSpan(buf, strong, em));
  178. buf = '';
  179. out.push(linkSpan(url, h));
  180. i = e + 1;
  181. }
  182. } else if ((c === 104 || c === 72) && (i === 0 || !isAlnumC(s.charCodeAt(i - 1))) && isUrlStart(s, i)) {
  183. const e = urlEnd(s, i);
  184. const url = s.slice(i, e);
  185. const h = safeHref(url);
  186. if (h == null) {
  187. buf += s[i];
  188. i++;
  189. } else {
  190. if (buf !== '') out.push(textSpan(buf, strong, em));
  191. buf = '';
  192. out.push(linkSpan(url, h));
  193. i = e;
  194. }
  195. } else if (c === 42 || c === 95) {
  196. let k = i;
  197. while (k < n && s.charCodeAt(k) === c) k++;
  198. const run = k - i;
  199. const prev = i > 0 ? s.charCodeAt(i - 1) : 32;
  200. const next = k < n ? s.charCodeAt(k) : 32;
  201. const wantStrong = run === 2 || run === 3;
  202. const wantEm = run === 1 || run === 3;
  203. const canOpen = !isSpaceC(next) && (c === 42 || !isAlnumC(prev));
  204. const canClose = !isSpaceC(prev) && (c === 42 || !isAlnumC(next));
  205. const closes = canClose && run <= 3 && (!wantStrong || (strong && strongC === c)) && (!wantEm || (em && emC === c));
  206. const opens = !closes && canOpen && run <= 3 && (!wantStrong || !strong) && (!wantEm || !em) && hasCloser(s, k, c, run);
  207. if (closes || opens) {
  208. if (buf !== '') out.push(textSpan(buf, strong, em));
  209. buf = '';
  210. if (wantStrong) { strong = opens; strongC = c; }
  211. if (wantEm) { em = opens; emC = c; }
  212. } else {
  213. buf += s.slice(i, k);
  214. }
  215. i = k;
  216. } else {
  217. buf += s[i];
  218. i++;
  219. }
  220. }
  221. if (buf !== '') out.push(textSpan(buf, strong, em));
  222. return out;
  223. }
  224. const noCr = (line) => (line.length > 0 && line.charCodeAt(line.length - 1) === 13 ? line.slice(0, line.length - 1) : line);
  225. const indentOf = (line) => {
  226. let i = 0;
  227. while (i < line.length && (line.charCodeAt(i) === 32 || line.charCodeAt(i) === 9)) i++;
  228. return i;
  229. };
  230. function headingOf(line) {
  231. if (indentOf(line) > 3) return null;
  232. const t = line.trim();
  233. let k = 0;
  234. while (k < t.length && t.charCodeAt(k) === 35) k++;
  235. if (k === 0 || k > 6) return null;
  236. if (k < t.length && t.charCodeAt(k) !== 32 && t.charCodeAt(k) !== 9) return null;
  237. let body = t.slice(k).trim();
  238. let e = body.length;
  239. while (e > 0 && body.charCodeAt(e - 1) === 35) e--;
  240. if (e === 0) body = '';
  241. else if (e < body.length && (body.charCodeAt(e - 1) === 32 || body.charCodeAt(e - 1) === 9)) body = body.slice(0, e).trim();
  242. return { level: k, text: body };
  243. }
  244. function itemOf(line) {
  245. const t = line.trim();
  246. if (t.length >= 2 && (t.charCodeAt(0) === 45 || t.charCodeAt(0) === 42 || t.charCodeAt(0) === 43) && (t.charCodeAt(1) === 32 || t.charCodeAt(1) === 9)) {
  247. return { ordered: false, start: 1, text: t.slice(2).trim() };
  248. }
  249. if (t.length === 1 && (t === '-' || t === '*' || t === '+')) return { ordered: false, start: 1, text: '' };
  250. let k = 0;
  251. while (k < t.length && k < 9 && t.charCodeAt(k) >= 48 && t.charCodeAt(k) <= 57) k++;
  252. if (k === 0 || k >= t.length) return null;
  253. const d = t.charCodeAt(k);
  254. if (d !== 46 && d !== 41) return null;
  255. if (k + 1 < t.length && t.charCodeAt(k + 1) !== 32 && t.charCodeAt(k + 1) !== 9) return null;
  256. return { ordered: true, start: Number(t.slice(0, k)), text: t.slice(k + 1).trim() };
  257. }
  258. function fenceOf(line) {
  259. if (indentOf(line) > 3) return null;
  260. const t = line.trim();
  261. if (t.length < 3) return null;
  262. const c = t.charCodeAt(0);
  263. if (c !== 96 && c !== 126) return null;
  264. let k = 0;
  265. while (k < t.length && t.charCodeAt(k) === c) k++;
  266. if (k < 3) return null;
  267. if (c === 96 && t.slice(k).includes('`')) return null;
  268. return t.slice(0, k);
  269. }
  270. const blockBase = () => ({ isP: false, isH1: false, isH2: false, isH3: false, isUl: false, isOl: false, isCode: false, spans: [], items: [], start: 1, code: '' });
  271. // the text → blocks, exactly as markdown.hl markdownBlocks. `ranges` (optional) receives, per
  272. // block, the source lines it came from ({ from, to } line indexes) and the heading level.
  273. function markdownBlocks(text, ranges) {
  274. const out = [];
  275. if (text == null || text === '') return out;
  276. const lines = String(text).split(NL);
  277. const rg = (from, to, level) => { if (ranges) ranges.push({ from, to, level }); };
  278. let para = [], paraFrom = 0;
  279. let inList = false, listOrdered = false, listStart = 1, items = [], listFrom = 0, listTo = 0;
  280. let blankBefore = false;
  281. const flushPara = () => {
  282. if (para.length > 0) {
  283. out.push(Object.assign(blockBase(), { isP: true, spans: inlineSpans(para.join(NL)) }));
  284. rg(paraFrom, paraFrom + para.length, 0);
  285. }
  286. para = [];
  287. };
  288. const flushList = () => {
  289. if (inList) {
  290. out.push(Object.assign(blockBase(), { isUl: !listOrdered, isOl: listOrdered, items: items.map((ls) => ({ spans: inlineSpans(ls.join(NL)) })), start: listStart }));
  291. rg(listFrom, listTo, 0);
  292. }
  293. inList = false;
  294. items = [];
  295. };
  296. let i = 0;
  297. while (i < lines.length) {
  298. const line = noCr(lines[i]);
  299. const fence = fenceOf(line);
  300. const h = fence == null ? headingOf(line) : null;
  301. const it = fence == null && h == null ? itemOf(line) : null;
  302. if (fence != null) {
  303. flushPara();
  304. flushList();
  305. const code = [];
  306. let j = i + 1, closed = false;
  307. const fc = fence.charCodeAt(0);
  308. while (j < lines.length && !closed) {
  309. const l = noCr(lines[j]);
  310. const f = fenceOf(l);
  311. if (f != null && f.charCodeAt(0) === fc && f.length >= fence.length && l.trim() === f) closed = true;
  312. else code.push(l);
  313. j++;
  314. }
  315. out.push(Object.assign(blockBase(), { isCode: true, code: code.join(NL) }));
  316. rg(i, j, 0);
  317. i = j;
  318. blankBefore = false;
  319. } else if (line.trim() === '') {
  320. flushPara();
  321. blankBefore = true;
  322. i++;
  323. } else if (h != null) {
  324. flushPara();
  325. flushList();
  326. out.push(Object.assign(blockBase(), { isH1: h.level === 1, isH2: h.level === 2, isH3: h.level >= 3, spans: inlineSpans(h.text) }));
  327. rg(i, i + 1, h.level);
  328. blankBefore = false;
  329. i++;
  330. } else if (it != null) {
  331. flushPara();
  332. if (inList && listOrdered !== it.ordered) flushList();
  333. if (!inList) {
  334. inList = true;
  335. listOrdered = it.ordered;
  336. listStart = it.start;
  337. items = [];
  338. listFrom = i;
  339. }
  340. items.push([it.text]);
  341. listTo = i + 1;
  342. blankBefore = false;
  343. i++;
  344. } else if (inList && (!blankBefore || indentOf(line) >= 2)) {
  345. items[items.length - 1].push(line.trim());
  346. listTo = i + 1;
  347. blankBefore = false;
  348. i++;
  349. } else {
  350. flushList();
  351. if (para.length === 0) paraFrom = i;
  352. para.push(line.trim());
  353. blankBefore = false;
  354. i++;
  355. }
  356. }
  357. flushPara();
  358. flushList();
  359. return out;
  360. }
  361. // =============================================================================================
  362. // 2. THE EDITOR MODEL — blocks of items of segments
  363. // block { t: 'p'|'h'|'ul'|'ol'|'code', level, start, items: [segs], o (index of the original
  364. // block it came from, -1 = new) }; p / h / code have exactly one item.
  365. // seg { x: text, s: strong, e: em, c: code span, h: href | null, a: auto link (a bare URL) }
  366. // =============================================================================================
  367. const seg = (x, s = false, e = false, c = false, h = null, a = false) => ({ x, s, e, c, h, a });
  368. const same = (p, q) => p.s === q.s && p.e === q.e && p.c === q.c && p.h === q.h && p.a === q.a;
  369. function merge(segs) {
  370. const out = [];
  371. for (const g of segs) {
  372. if (!g.x) continue;
  373. const last = out[out.length - 1];
  374. if (last && same(last, g)) last.x += g.x;
  375. else out.push(seg(g.x, !!g.s, !!g.e, !!g.c, g.h || null, !!g.a));
  376. }
  377. return out;
  378. }
  379. function toChars(segs) {
  380. const out = [];
  381. for (const g of segs) for (let i = 0; i < g.x.length; i++) out.push({ x: g.x[i], s: !!g.s, e: !!g.e, c: !!g.c, h: g.h || null, a: !!g.a });
  382. return out;
  383. }
  384. const fromChars = (chars) => merge(chars);
  385. const textOf = (segs) => segs.map((g) => g.x).join('');
  386. const lenOf = (segs) => segs.reduce((n, g) => n + g.x.length, 0);
  387. const isWs = (ch) => ch === ' ' || ch === '\t' || ch === NL || ch === '\r';
  388. const block = (t, items, extra) => Object.assign({ t, level: 1, start: 1, items, o: -1 }, extra || {});
  389. const clone = (doc) => doc.map((b) => Object.assign({}, b, { items: b.items.map((it) => it.map((g) => Object.assign({}, g))) }));
  390. function spansToSegs(spans) {
  391. return merge(spans.map((sp) => {
  392. if (sp.isCode) return seg(sp.text, false, false, true);
  393. if (sp.isLink) return seg(sp.text, false, false, false, sp.href, sp.text === sp.href);
  394. return seg(sp.text, sp.isStrong || sp.isStrongEm, sp.isEm || sp.isStrongEm);
  395. }));
  396. }
  397. function fromParsed(pb, level) {
  398. if (pb.isP) return block('p', [spansToSegs(pb.spans)]);
  399. if (pb.isH1 || pb.isH2 || pb.isH3) return block('h', [spansToSegs(pb.spans)], { level: level || (pb.isH1 ? 1 : pb.isH2 ? 2 : 3) });
  400. if (pb.isUl) return block('ul', pb.items.map((it) => spansToSegs(it.spans)));
  401. if (pb.isOl) return block('ol', pb.items.map((it) => spansToSegs(it.spans)), { start: pb.start });
  402. return block('code', [pb.code ? [seg(pb.code)] : []]);
  403. }
  404. // ---- editor normalisation: what the model may hold at all ---------------------------------
  405. // (code blocks plain, headings one line, code spans / links carry no emphasis and no line
  406. // break, bare URLs are links — as the renderer shows them — and adjacent lists of one kind are
  407. // one list, because Markdown cannot keep them apart)
  408. function autolink(chars) {
  409. // demote auto links that were typed into or touch text (they are re-detected below)
  410. let i = 0;
  411. while (i < chars.length) {
  412. if (chars[i].a && chars[i].h) {
  413. let k = i;
  414. while (k < chars.length && chars[k].a && chars[k].h === chars[i].h) k++;
  415. const text = chars.slice(i, k).map((c) => c.x).join('');
  416. const before = i > 0 ? chars[i - 1] : null;
  417. const after = k < chars.length ? chars[k] : null;
  418. const touches = (c) => c && !c.h && !c.c && !isWs(c.x);
  419. if (text !== chars[i].h || touches(before) || touches(after)) {
  420. for (let j = i; j < k; j++) { chars[j].h = null; chars[j].a = false; }
  421. }
  422. i = k;
  423. } else i++;
  424. }
  425. // detect bare URLs in runs of plain text, the way the renderer does
  426. i = 0;
  427. while (i < chars.length) {
  428. if (chars[i].h || chars[i].c) { i++; continue; }
  429. let k = i;
  430. while (k < chars.length && !chars[k].h && !chars[k].c) k++;
  431. const T = chars.slice(i, k).map((c) => c.x).join('');
  432. let j = 0;
  433. while (j < T.length) {
  434. const c = T.charCodeAt(j);
  435. const prevC = j > 0 ? T.charCodeAt(j - 1) : (i > 0 && !chars[i - 1].h && !chars[i - 1].c ? chars[i - 1].x.charCodeAt(0) : 32);
  436. if ((c === 104 || c === 72) && !isAlnumC(prevC) && isUrlStart(T, j)) {
  437. const e = urlEnd(T, j);
  438. const url = T.slice(j, e);
  439. const h = safeHref(url);
  440. if (h != null) {
  441. for (let m = i + j; m < i + e; m++) Object.assign(chars[m], { h: url, a: true, s: false, e: false, c: false });
  442. j = e;
  443. continue;
  444. }
  445. }
  446. j++;
  447. }
  448. i = k;
  449. }
  450. return chars;
  451. }
  452. function edItem(segs, t) {
  453. let chars = toChars(segs);
  454. if (t === 'code') return chars.length ? [seg(chars.map((c) => c.x).join(''))] : [];
  455. for (const ch of chars) {
  456. if (ch.h) { ch.s = false; ch.e = false; ch.c = false; }
  457. if (ch.c) { ch.s = false; ch.e = false; ch.h = null; ch.a = false; }
  458. if (!ch.h) ch.a = false;
  459. if ((ch.c || ch.h) && (ch.x === NL || ch.x === '\r')) ch.x = ' ';
  460. if (t === 'h' && (ch.x === NL || ch.x === '\r')) ch.x = ' ';
  461. }
  462. chars = autolink(chars);
  463. return fromChars(chars);
  464. }
  465. function edDoc(doc) {
  466. const out = [];
  467. for (const b0 of doc) {
  468. const b = Object.assign({}, b0);
  469. if (b.t === 'code') {
  470. b.items = [edItem([seg(b.items.map(textOf).join(NL))], 'code')];
  471. } else if (b.t === 'p' || b.t === 'h') {
  472. b.items = [edItem(b.items.length ? [].concat(...b.items.map((it, k) => (k ? [seg(NL)].concat(it) : it))) : [], b.t)];
  473. } else {
  474. b.items = (b.items.length ? b.items : [[]]).map((it) => edItem(it, b.t));
  475. }
  476. if (b.t === 'h') b.level = Math.min(6, Math.max(1, b.level | 0 || 1));
  477. if (b.t === 'ol') b.start = Number.isFinite(b.start) && b.start >= 0 ? b.start : 1;
  478. const prev = out[out.length - 1];
  479. if (prev && (b.t === 'ul' || b.t === 'ol') && prev.t === b.t) {
  480. prev.items = prev.items.concat(b.items);
  481. prev.o = -1;
  482. continue;
  483. }
  484. out.push(b);
  485. }
  486. if (!out.length) out.push(block('p', [[]]));
  487. return out;
  488. }
  489. // ---- what is stored: empty blocks, empty lines and line-edge spaces cannot be kept ---------
  490. function trimLines(chars, dropEmpty) {
  491. // spaces/tabs at the item's edges and around line breaks go; empty lines go (dropEmpty) or
  492. // split the item (returned as several)
  493. const lines = [[]];
  494. for (const ch of chars) {
  495. if (ch.x === NL) lines.push([]);
  496. else if (ch.x !== '\r') lines[lines.length - 1].push(ch);
  497. }
  498. const trimmed = lines.map((l) => {
  499. let a = 0, b = l.length;
  500. while (a < b && (l[a].x === ' ' || l[a].x === '\t')) a++;
  501. while (b > a && (l[b - 1].x === ' ' || l[b - 1].x === '\t')) b--;
  502. return l.slice(a, b);
  503. });
  504. const groups = [[]];
  505. for (const l of trimmed) {
  506. if (l.length === 0) { if (!dropEmpty && groups[groups.length - 1].length) groups.push([]); continue; }
  507. groups[groups.length - 1].push(l);
  508. }
  509. return groups.filter((g) => g.length).map((g) => {
  510. const out = [];
  511. g.forEach((l, k) => { if (k) out.push({ x: NL, s: false, e: false, c: false, h: null, a: false }); out.push(...l); });
  512. return out;
  513. });
  514. }
  515. function fixLabels(chars) {
  516. for (const ch of chars) if (ch.h) { if (ch.x === '[') ch.x = '('; if (ch.x === ']') ch.x = ')'; }
  517. return chars;
  518. }
  519. function storable(b) {
  520. if (b.t === 'code') {
  521. const text = textOf(b.items[0] || []);
  522. return text === '' ? [] : [block('code', [[seg(text)]])];
  523. }
  524. if (b.t === 'p') return trimLines(fixLabels(toChars(b.items[0] || [])), false).map((cs) => block('p', [fromChars(cs)]));
  525. if (b.t === 'h') {
  526. const g = trimLines(fixLabels(toChars(b.items[0] || [])), true);
  527. return g.length ? [block('h', [fromChars(g[0])], { level: b.level })] : [];
  528. }
  529. const items = [];
  530. for (const it of b.items) {
  531. const g = trimLines(fixLabels(toChars(it)), true);
  532. if (g.length) items.push(fromChars(g[0]));
  533. }
  534. return items.length ? [block(b.t, items, { start: b.start })] : [];
  535. }
  536. // ---- the semantic key: what the renderer would show (two texts with the same key look the same)
  537. function itemKey(segs) {
  538. const chars = toChars(segs);
  539. for (const ch of chars) if (!ch.c && !ch.h && isWs(ch.x)) { ch.s = false; ch.e = false; }
  540. return merge(chars.map((ch) => Object.assign(ch, { a: false }))).map((g) => [g.x, g.s ? 1 : 0, g.e ? 1 : 0, g.c ? 1 : 0, g.h || '']);
  541. }
  542. function blockKey(b) {
  543. const kind = b.t === 'h' ? 'h' + Math.min(3, b.level) : b.t;
  544. if (b.t === 'code') return JSON.stringify(['code', textOf(b.items[0] || [])]);
  545. return JSON.stringify([kind, b.t === 'ol' ? b.start : 0, b.items.map(itemKey)]);
  546. }
  547. const keysOf = (blocks) => blocks.map(blockKey).join(NL);
  548. // the stored form of parsed Markdown (as the editor would hold it), as keys
  549. function parsedKeys(md) {
  550. const ranges = [];
  551. const pbs = markdownBlocks(md, ranges);
  552. const doc = edDoc(pbs.map((pb, k) => fromParsed(pb, ranges[k].level)));
  553. return keysOf([].concat(...doc.map(storable)));
  554. }
  555. // =============================================================================================
  556. // 3. MODEL → MARKDOWN (canonical, verified)
  557. // =============================================================================================
  558. const VARIANTS = [
  559. { em: '*', st: '**', auto: 'bare' }, { em: '*', st: '**', auto: 'angle' },
  560. { em: '_', st: '**', auto: 'bare' }, { em: '_', st: '**', auto: 'angle' },
  561. { em: '*', st: '__', auto: 'angle' }, { em: '_', st: '__', auto: 'angle' },
  562. ];
  563. function codeMd(x) {
  564. let run = 0, longest = 0;
  565. for (const ch of x) { if (ch === '`') { run++; longest = Math.max(longest, run); } else run = 0; }
  566. const fence = '`'.repeat(longest + 1);
  567. const pad = x.startsWith('`') || x.endsWith('`') || (x.startsWith(' ') && x.endsWith(' ') && x.trim() !== '') ? ' ' : '';
  568. return fence + pad + x + pad + fence;
  569. }
  570. // an auto link is written bare where the renderer finds exactly it again (prev not a letter,
  571. // the URL does not run on into what follows), else in <…>
  572. function linkMd(g, v, prev, after) {
  573. if (g.a && g.x === g.h && /^https?:\/\//i.test(g.h)) {
  574. const bare = v.auto === 'bare' && !isAlnumC(prev.charCodeAt(0) || 32) && urlEnd(g.x + after, 0) === g.x.length;
  575. return bare ? g.x : '<' + g.x + '>';
  576. }
  577. return '[' + g.x + '](' + g.h.replace(/\)/g, '%29') + ')';
  578. }
  579. // pieces: strings (fixed) and { ch, risky, esc } (a text character that may need a backslash)
  580. function buildPieces(segs, v, ctx) {
  581. const out = [];
  582. let line = '';
  583. const put = (str) => { out.push(str); const k = str.lastIndexOf(NL); line = k >= 0 ? str.slice(k + 1) : line + str; };
  584. const putCh = (ch) => {
  585. const lead = line.trim() === '';
  586. let risky = false;
  587. if ('\\`*_[<'.includes(ch)) risky = true;
  588. else if (ch === '#') risky = lead || ctx === 'h';
  589. else if ('-+~'.includes(ch)) risky = lead;
  590. else if (ch === '.' || ch === ')') risky = /^\s*\d+$/.test(line);
  591. else if (ch === ':') risky = /https?$/i.test(line);
  592. if (ch === NL) { put(NL); return; }
  593. out.push({ ch, risky, esc: false });
  594. line += ch;
  595. };
  596. let cur = { s: false, e: false }, pend = '';
  597. const markers = (next) => {
  598. let m = '';
  599. if (cur.e && !next.e) m += v.em;
  600. if (cur.s && !next.s) m += v.st;
  601. let o = '';
  602. if (!cur.s && next.s) o += v.st;
  603. if (!cur.e && next.e) o += v.em;
  604. return [m, o];
  605. };
  606. const flushPend = () => { for (const ch of pend) putCh(ch); pend = ''; };
  607. const ms = merge(segs);
  608. ms.forEach((g, k) => {
  609. if (g.h) {
  610. flushPend();
  611. const nx = ms[k + 1];
  612. const after = !nx ? '' : nx.h ? '[' : nx.c ? '`' : (cur.s || cur.e) && !(nx.s === cur.s && nx.e === cur.e) ? '*' : nx.x;
  613. put(linkMd(g, v, line.slice(-1), after));
  614. return;
  615. }
  616. if (g.c) { flushPend(); put(codeMd(g.x)); return; }
  617. let a = 0, b = g.x.length;
  618. while (a < b && isWs(g.x[a])) a++;
  619. while (b > a && isWs(g.x[b - 1])) b--;
  620. if (a === b) { pend += g.x; return; }
  621. const [close, open] = markers(g);
  622. if (close) put(close);
  623. flushPend();
  624. for (const ch of g.x.slice(0, a)) putCh(ch);
  625. if (open) put(open);
  626. for (const ch of g.x.slice(a, b)) putCh(ch);
  627. pend = g.x.slice(b);
  628. cur = { s: g.s, e: g.e };
  629. });
  630. const [close] = markers({ s: false, e: false });
  631. if (close) put(close);
  632. flushPend();
  633. return out;
  634. }
  635. const piecesStr = (ps) => ps.map((p) => (typeof p === 'string' ? p : p.esc ? '\\' + p.ch : p.ch)).join('');
  636. const wrapCtx = (md, ctx, level) => (ctx === 'h' ? '#'.repeat(level) + ' ' + md : ctx === 'li' ? '- ' + md.split(NL).join(NL + ' ') : md);
  637. const inlineCache = new Map();
  638. function inlineMd(segs, ctx, level) {
  639. const ck = ctx + (level || '') + JSON.stringify(segs);
  640. if (inlineCache.has(ck)) return inlineCache.get(ck);
  641. const target = JSON.stringify(itemKey(segs));
  642. const check = (md) => {
  643. const ranges = [];
  644. const bl = markdownBlocks(wrapCtx(md, ctx, level), ranges);
  645. if (bl.length !== 1) return md === '' && segs.length === 0;
  646. const b = edDoc([fromParsed(bl[0], ranges[0].level)])[0];
  647. if (ctx === 'p' && b.t !== 'p') return false;
  648. if (ctx === 'h' && b.t !== 'h') return false;
  649. if (ctx === 'li' && (b.t !== 'ul' || b.items.length !== 1)) return false;
  650. return JSON.stringify(itemKey(b.items[0])) === target;
  651. };
  652. let result = null;
  653. for (const v of VARIANTS) {
  654. const s = piecesStr(buildPieces(segs, v, ctx));
  655. if (check(s)) { result = s; break; }
  656. }
  657. if (result == null) {
  658. for (const v of VARIANTS) {
  659. const ps = buildPieces(segs, v, ctx);
  660. const risky = ps.filter((p) => typeof p !== 'string' && p.risky);
  661. risky.forEach((p) => { p.esc = true; });
  662. if (!check(piecesStr(ps))) continue;
  663. for (const p of risky) { p.esc = false; if (!check(piecesStr(ps))) p.esc = true; }
  664. result = piecesStr(ps);
  665. break;
  666. }
  667. }
  668. if (result == null) {
  669. // cannot be written exactly (should not happen): the fully escaped first variant
  670. const ps = buildPieces(segs, VARIANTS[0], ctx);
  671. ps.forEach((p) => { if (typeof p !== 'string' && p.risky) p.esc = true; });
  672. result = piecesStr(ps);
  673. }
  674. if (inlineCache.size > 2000) inlineCache.clear();
  675. inlineCache.set(ck, result);
  676. return result;
  677. }
  678. function blockMd(b) {
  679. if (b.t === 'p') return inlineMd(b.items[0], 'p');
  680. if (b.t === 'h') return '#'.repeat(b.level) + ' ' + inlineMd(b.items[0], 'h', b.level);
  681. if (b.t === 'ul') return b.items.map((it) => '- ' + inlineMd(it, 'li').split(NL).join(NL + ' ')).join(NL);
  682. if (b.t === 'ol') {
  683. return b.items.map((it, k) => {
  684. const mark = String(b.start + k) + '. ';
  685. return mark + inlineMd(it, 'li').split(NL).join(NL + ' '.repeat(mark.length));
  686. }).join(NL);
  687. }
  688. const text = textOf(b.items[0] || []);
  689. let n = 3;
  690. for (const l of text.split(NL)) { const m = /^\s*(`{3,})/.exec(l); if (m && m[1].length >= n) n = m[1].length + 1; }
  691. const fence = '`'.repeat(n);
  692. return fence + NL + text + NL + fence;
  693. }
  694. // the original text, cut into its blocks (sources, the lines between them, their keys)
  695. function prepareOriginal(md) {
  696. const text = md == null ? '' : String(md);
  697. const ranges = [];
  698. const pbs = markdownBlocks(text, ranges);
  699. const lines = text.split(NL);
  700. const doc = pbs.map((pb, k) => Object.assign(fromParsed(pb, ranges[k].level), { o: k }));
  701. const src = ranges.map((r) => lines.slice(r.from, r.to).join(NL));
  702. const keys = doc.map((b) => keysOf([].concat(...edDoc([b]).map(storable))));
  703. const joiners = ranges.map((r, k) => (k === 0 ? '' : NL + lines.slice(ranges[k - 1].to, r.from).map((l) => l + NL).join('')));
  704. const prefix = ranges.length ? lines.slice(0, ranges[0].from).map((l) => l + NL).join('') : '';
  705. const suffix = ranges.length ? lines.slice(ranges[ranges.length - 1].to).map((l) => NL + l).join('') : '';
  706. return { text, doc, src, keys, joiners, prefix, suffix, n: ranges.length };
  707. }
  708. function serialize(doc, orig) {
  709. const parts = [];
  710. const used = new Set();
  711. for (const b of doc) {
  712. const st = storable(b);
  713. const key = keysOf(st);
  714. const from = orig && b.o >= 0 && b.o < orig.n && !used.has(b.o) ? b.o : -1;
  715. if (from >= 0 && orig.keys[from] === key && key !== '') {
  716. used.add(from);
  717. parts.push({ md: orig.src[from], first: from, last: from, key });
  718. continue;
  719. }
  720. if (from >= 0) used.add(from);
  721. // a changed block is written anew, but keeps the blank lines around it
  722. st.forEach((sb, k) => parts.push({ md: blockMd(sb), first: k === 0 ? from : -1, last: k === st.length - 1 ? from : -1, key: blockKey(sb) }));
  723. }
  724. const expected = parts.map((p) => p.key).join(NL);
  725. let out = '';
  726. parts.forEach((p, k) => {
  727. if (k === 0) out += orig && p.first === 0 ? orig.prefix : '';
  728. else out += orig && p.first > 0 && parts[k - 1].last === p.first - 1 ? orig.joiners[p.first] : NL + NL;
  729. out += p.md;
  730. });
  731. if (orig && parts.length && parts[parts.length - 1].last === orig.n - 1 && orig.n > 0) out += orig.suffix;
  732. if (parsedKeys(out) === expected) return out;
  733. // the neighbours changed how a kept block reads: everything canonical
  734. const canon = [].concat(...doc.map(storable)).map(blockMd).join(NL + NL);
  735. return canon;
  736. }
  737. // =============================================================================================
  738. // 4. MODEL OPERATIONS on linear positions (every item counts its length + 1)
  739. // =============================================================================================
  740. function fromLin(doc, n) {
  741. let rest = Math.max(0, n);
  742. for (let b = 0; b < doc.length; b++) {
  743. for (let i = 0; i < doc[b].items.length; i++) {
  744. const L = lenOf(doc[b].items[i]);
  745. if (rest <= L) return { b, i, o: rest };
  746. rest -= L + 1;
  747. }
  748. }
  749. const b = doc.length - 1, i = doc[b].items.length - 1;
  750. return { b, i, o: lenOf(doc[b].items[i]) };
  751. }
  752. function toLin(doc, p) {
  753. let n = 0;
  754. for (let b = 0; b < p.b; b++) for (const it of doc[b].items) n += lenOf(it) + 1;
  755. for (let i = 0; i < p.i; i++) n += lenOf(doc[p.b].items[i]) + 1;
  756. return n + p.o;
  757. }
  758. const docLen = (doc) => toLin(doc, { b: doc.length - 1, i: doc[doc.length - 1].items.length - 1, o: lenOf(doc[doc.length - 1].items[doc[doc.length - 1].items.length - 1]) });
  759. function delRange(doc, a, b) {
  760. if (b <= a) return doc;
  761. const p = fromLin(doc, a), q = fromLin(doc, b);
  762. const bp = doc[p.b], bq = doc[q.b];
  763. const merged = fromChars(toChars(bp.items[p.i]).slice(0, p.o).concat(toChars(bq.items[q.i]).slice(q.o)));
  764. if (p.b === q.b) {
  765. bp.items.splice(p.i, q.i - p.i + 1, merged);
  766. return doc;
  767. }
  768. const rest = bq.items.slice(q.i + 1);
  769. bp.items = bp.items.slice(0, p.i).concat([merged]);
  770. const repl = rest.length ? [block(bq.t, rest, { level: bq.level, start: bq.start + q.i + 1 })] : [];
  771. doc.splice(p.b + 1, q.b - p.b, ...repl);
  772. return doc;
  773. }
  774. function insChars(doc, a, chars) {
  775. const p = fromLin(doc, a);
  776. const it = toChars(doc[p.b].items[p.i]);
  777. it.splice(p.o, 0, ...chars);
  778. doc[p.b].items[p.i] = fromChars(it);
  779. return a + chars.length;
  780. }
  781. const plainChars = (text, attrs) => Array.from(text).flatMap((u) => u.split('')).map((x) => Object.assign({ x, s: false, e: false, c: false, h: null, a: false }, attrs || {}));
  782. function opEnter(doc, a) {
  783. const p = fromLin(doc, a);
  784. const blk = doc[p.b];
  785. const chars = toChars(blk.items[p.i]);
  786. const head = fromChars(chars.slice(0, p.o)), tail = fromChars(chars.slice(p.o));
  787. if (blk.t === 'code') {
  788. const text = chars.map((c) => c.x).join('');
  789. if (p.o === text.length && text.endsWith(NL)) {
  790. blk.items[0] = fromChars(chars.slice(0, -1));
  791. doc.splice(p.b + 1, 0, block('p', [[]]));
  792. return toLin(doc, { b: p.b + 1, i: 0, o: 0 });
  793. }
  794. return insChars(doc, a, plainChars(NL));
  795. }
  796. if (blk.t === 'ul' || blk.t === 'ol') {
  797. if (chars.every((c) => isWs(c.x))) {
  798. const before = blk.items.slice(0, p.i), after = blk.items.slice(p.i + 1);
  799. const repl = [];
  800. if (before.length) repl.push(block(blk.t, before, { start: blk.start, o: blk.o }));
  801. repl.push(block('p', [[]]));
  802. if (after.length) repl.push(block(blk.t, after, { start: blk.start + p.i + 1 }));
  803. doc.splice(p.b, 1, ...repl);
  804. return toLin(doc, { b: p.b + (before.length ? 1 : 0), i: 0, o: 0 });
  805. }
  806. blk.items.splice(p.i, 1, head, tail);
  807. return toLin(doc, { b: p.b, i: p.i + 1, o: 0 });
  808. }
  809. if (blk.t === 'h' && p.o === 0 && chars.length) {
  810. doc.splice(p.b, 0, block('p', [[]]));
  811. return toLin(doc, { b: p.b + 1, i: 0, o: 0 });
  812. }
  813. blk.items[0] = head;
  814. doc.splice(p.b + 1, 0, block('p', [tail]));
  815. return toLin(doc, { b: p.b + 1, i: 0, o: 0 });
  816. }
  817. function opBackspaceAtStart(doc, a) {
  818. const p = fromLin(doc, a);
  819. const blk = doc[p.b];
  820. if (blk.t === 'h' || blk.t === 'code') { blk.t = 'p'; blk.o = -1; return a; }
  821. if (blk.t === 'ul' || blk.t === 'ol') {
  822. if (p.i === 0) {
  823. const rest = blk.items.slice(1);
  824. const repl = [block('p', [blk.items[0]])];
  825. if (rest.length) repl.push(block(blk.t, rest, { start: blk.start + 1 }));
  826. doc.splice(p.b, 1, ...repl);
  827. return a;
  828. }
  829. blk.items.splice(p.i - 1, 2, blk.items[p.i - 1].concat(blk.items[p.i]));
  830. return a - 1;
  831. }
  832. if (p.b === 0) return a;
  833. const prev = doc[p.b - 1];
  834. if ((prev.t === 'p' || prev.t === 'h') && lenOf(prev.items[0]) === 0) {
  835. doc.splice(p.b - 1, 1);
  836. return a - 1;
  837. }
  838. const last = prev.items.length - 1;
  839. prev.items[last] = prev.items[last].concat(blk.items[0]);
  840. doc.splice(p.b, 1);
  841. return a - 1;
  842. }
  843. function opDeleteAtEnd(doc, a) {
  844. const p = fromLin(doc, a);
  845. const blk = doc[p.b];
  846. if ((blk.t === 'ul' || blk.t === 'ol') && p.i < blk.items.length - 1) {
  847. blk.items.splice(p.i, 2, blk.items[p.i].concat(blk.items[p.i + 1]));
  848. return a;
  849. }
  850. if (p.b >= doc.length - 1) return a;
  851. if (blk.t === 'p' && lenOf(blk.items[0]) === 0) { doc.splice(p.b, 1); return a; }
  852. const next = doc[p.b + 1];
  853. blk.items[p.i] = blk.items[p.i].concat(next.items[0]);
  854. next.items.shift();
  855. if (!next.items.length) doc.splice(p.b + 1, 1);
  856. return a;
  857. }
  858. function opBlockType(doc, a, b, type, level) {
  859. const P = fromLin(doc, a), Q = fromLin(doc, b);
  860. const blocks = doc.slice(P.b, Q.b + 1);
  861. const is = (blk) => (type === 'h' ? blk.t === 'h' && blk.level === level : blk.t === type);
  862. const target = blocks.every(is) ? 'p' : type;
  863. const lines = (it) => {
  864. const out = [[]];
  865. for (const ch of toChars(it)) { if (ch.x === NL) out.push([]); else out[out.length - 1].push(ch); }
  866. return out.map(fromChars);
  867. };
  868. let repl = [];
  869. if (target === 'code') {
  870. const text = [].concat(...blocks.map((blk) => blk.items)).map(textOf).join(NL);
  871. repl = [block('code', [text ? [seg(text)] : []])];
  872. } else if (target === 'ul' || target === 'ol') {
  873. const items = [];
  874. for (const blk of blocks) {
  875. if (blk.t === 'p' || blk.t === 'code') items.push(...lines(blk.items[0]));
  876. else items.push(...blk.items);
  877. }
  878. const first = blocks.find((blk) => blk.t === 'ol');
  879. repl = [block(target, items, { start: target === 'ol' && first ? first.start : 1 })];
  880. } else {
  881. for (const blk of blocks) {
  882. for (const it of blk.items) repl.push(block(target, [it], { level: level || 1 }));
  883. }
  884. }
  885. doc.splice(P.b, blocks.length, ...repl);
  886. return doc;
  887. }
  888. // every item overlapping [a, b) (code blocks skipped): fn(chars, from, to) edits chars in place
  889. function eachInRange(doc, a, b, fn) {
  890. let n = 0;
  891. for (const blk of doc) {
  892. for (let i = 0; i < blk.items.length; i++) {
  893. const L = lenOf(blk.items[i]);
  894. const from = Math.max(a, n), to = Math.min(b, n + L);
  895. if (from < to && blk.t !== 'code') {
  896. const chars = toChars(blk.items[i]);
  897. fn(chars, from - n, to - n);
  898. blk.items[i] = fromChars(chars);
  899. }
  900. n += L + 1;
  901. }
  902. }
  903. }
  904. function charsInRange(doc, a, b) {
  905. const out = [];
  906. eachInRange(clone(doc), a, b, (chars, f, t) => { out.push(...chars.slice(f, t)); });
  907. return out;
  908. }
  909. function opToggle(doc, a, b, attr) {
  910. const all = charsInRange(doc, a, b).filter((ch) => !isWs(ch.x) && (attr === 'c' ? !ch.h : !ch.c && !ch.h));
  911. const on = !(all.length && all.every((ch) => ch[attr]));
  912. eachInRange(doc, a, b, (chars, f, t) => {
  913. for (let k = f; k < t; k++) {
  914. const ch = chars[k];
  915. if (ch.x === NL) continue;
  916. if (attr === 'c') {
  917. if (ch.h) continue;
  918. ch.c = on;
  919. if (on) { ch.s = false; ch.e = false; }
  920. } else if (!ch.c && !ch.h) ch[attr] = on;
  921. }
  922. });
  923. return doc;
  924. }
  925. function opLink(doc, a, b, href) {
  926. eachInRange(doc, a, b, (chars, f, t) => {
  927. for (let k = f; k < t; k++) {
  928. const ch = chars[k];
  929. if (ch.x === NL) continue;
  930. if (href) Object.assign(ch, { h: href, a: false, s: false, e: false, c: false });
  931. else Object.assign(ch, { h: null, a: false });
  932. }
  933. });
  934. return doc;
  935. }
  936. function linkExtent(doc, a) {
  937. const p = fromLin(doc, a);
  938. const chars = toChars(doc[p.b].items[p.i]);
  939. let k = p.o < chars.length && chars[p.o].h ? p.o : p.o > 0 && chars[p.o - 1].h ? p.o - 1 : -1;
  940. if (k < 0) return null;
  941. const h = chars[k].h;
  942. let f = k, t = k;
  943. while (f > 0 && chars[f - 1].h === h) f--;
  944. while (t < chars.length && chars[t].h === h) t++;
  945. const base = a - p.o;
  946. return { a: base + f, b: base + t, href: h, auto: chars[k].a };
  947. }
  948. // =============================================================================================
  949. // 5. DOM ⇄ MODEL
  950. // =============================================================================================
  951. const BLOCK_TAGS = new Set(['P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'UL', 'OL', 'LI', 'PRE', 'BLOCKQUOTE', 'SECTION', 'ARTICLE', 'HEADER', 'FOOTER', 'ASIDE', 'NAV', 'MAIN', 'TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TR', 'HR', 'DL', 'DT', 'DD', 'FIGURE', 'FIGCAPTION', 'ADDRESS', 'FORM', 'FIELDSET', 'DETAILS', 'SUMMARY']);
  952. const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'TEMPLATE', 'NOSCRIPT', 'IFRAME', 'OBJECT', 'EMBED', 'SVG', 'MATH', 'CANVAS', 'VIDEO', 'AUDIO', 'HEAD', 'META', 'LINK', 'TITLE', 'BUTTON', 'SELECT', 'INPUT', 'TEXTAREA', 'IMG', 'PICTURE', 'SOURCE']);
  953. // reads DOM content into items. `opts.html` = foreign HTML (paste): whitespace collapses and
  954. // styles (font-weight / font-style) count; else it is our own editor DOM. `marks`: DOM points
  955. // whose model positions are wanted ({ node, off } → .lin).
  956. function readDom(root, opts) {
  957. const html = !!(opts && opts.html);
  958. const marks = (opts && opts.marks) || [];
  959. const doc = [];
  960. let lin = 0; // linear position at the start of the open item
  961. let cur = null; // the open item: { blk, chars, marks: [] }
  962. let waiting = []; // marks met between items: they belong to the start of the next one
  963. const open = (blk) => {
  964. close();
  965. cur = { blk, chars: [], marks: [] };
  966. for (const m of waiting) { m.rel = 0; cur.marks.push(m); }
  967. waiting = [];
  968. };
  969. const close = () => {
  970. if (!cur) return;
  971. const chars = cur.chars;
  972. if (!html && chars.length && chars[chars.length - 1].x === NL) chars.pop(); // the caret's filler <br>
  973. if (html) {
  974. // collapsed HTML whitespace: none at the item's edges or around line breaks
  975. const lines = [[]];
  976. for (const ch of chars) { if (ch.x === NL) lines.push([]); else lines[lines.length - 1].push(ch); }
  977. const t = lines.map((l) => { let a = 0, b = l.length; while (a < b && l[a].x === ' ') a++; while (b > a && l[b - 1].x === ' ') b--; return l.slice(a, b); });
  978. chars.length = 0;
  979. t.forEach((l, k) => { if (k) chars.push({ x: NL, s: false, e: false, c: false, h: null, a: false }); chars.push(...l); });
  980. }
  981. for (const m of cur.marks) m.lin = lin + Math.min(m.rel, chars.length);
  982. const blk = cur.blk;
  983. if (!blk.pushed) { blk.pushed = true; doc.push(blk); }
  984. blk.items.push(fromChars(chars));
  985. lin += chars.length + 1;
  986. cur = null;
  987. };
  988. const ensure = () => { if (!cur) open(block('p', [])); };
  989. const markAt = (node, off) => {
  990. for (const m of marks) {
  991. if (m.node !== node || m.off !== off || m.lin != null) continue;
  992. m.lin = -2;
  993. if (cur) { cur.marks.push(m); m.rel = cur.chars.length; } else waiting.push(m);
  994. }
  995. };
  996. const pushText = (s, at) => {
  997. ensure();
  998. for (let k = 0; k < s.length; k++) cur.chars.push({ x: s[k], s: !!at.s, e: !!at.e, c: !!at.c, h: at.h || null, a: !!at.a });
  999. };
  1000. const attrsOf = (el, at) => {
  1001. const t = el.tagName;
  1002. const n = Object.assign({}, at);
  1003. if (t === 'STRONG' || t === 'B') n.s = true;
  1004. if (t === 'EM' || t === 'I' || t === 'CITE' || t === 'DFN' || t === 'VAR') n.e = true;
  1005. if (t === 'CODE' || t === 'KBD' || t === 'SAMP' || t === 'TT') n.c = true;
  1006. if (t === 'A') {
  1007. const h = safeHref(el.getAttribute('href'));
  1008. if (h) { n.h = h; n.a = !html && el.hasAttribute('data-auto'); }
  1009. }
  1010. if (html && el.style) {
  1011. const fw = el.style.fontWeight;
  1012. if (fw === 'bold' || fw === 'bolder' || Number(fw) >= 600) n.s = true;
  1013. if (fw === 'normal' || fw === 'lighter' || (Number(fw) > 0 && Number(fw) < 600)) n.s = false;
  1014. if (el.style.fontStyle === 'italic' || el.style.fontStyle === 'oblique') n.e = true;
  1015. if (el.style.fontStyle === 'normal') n.e = false;
  1016. }
  1017. return n;
  1018. };
  1019. const textOfPre = (el) => {
  1020. let s = '';
  1021. const walk = (n) => {
  1022. for (const c of n.childNodes) {
  1023. if (c.nodeType === 3) s += c.data;
  1024. else if (c.nodeType === 1 && c.tagName === 'BR') s += NL;
  1025. else if (c.nodeType === 1 && !SKIP_TAGS.has(c.tagName)) walk(c);
  1026. }
  1027. };
  1028. walk(el);
  1029. return s;
  1030. };
  1031. const inline = (n, at, pre) => {
  1032. if (n.nodeType === 3) {
  1033. let s = n.data;
  1034. if (html && !pre) s = s.replace(/[\t\n\r\f ]+/g, ' ');
  1035. else s = s.replace(/\r\n?/g, NL);
  1036. if (!html) { for (const m of marks) if (m.node === n && m.lin == null) { ensure(); cur.marks.push(m); m.rel = cur.chars.length + Math.min(m.off, s.length); m.lin = -2; } }
  1037. if (s) pushText(s, at);
  1038. return;
  1039. }
  1040. if (n.nodeType !== 1 || SKIP_TAGS.has(n.tagName)) {
  1041. if (n.nodeType === 1 && n.tagName === 'IMG' && html && n.getAttribute('alt')) pushText(n.getAttribute('alt'), at);
  1042. return;
  1043. }
  1044. if (n.tagName === 'BR') { ensure(); cur.chars.push({ x: NL, s: false, e: false, c: false, h: null, a: false }); return; }
  1045. if (BLOCK_TAGS.has(n.tagName)) { blockEl(n, at); return; }
  1046. if ((n.tagName === 'TD' || n.tagName === 'TH') && cur && cur.chars.length) pushText(' ', {});
  1047. const na = attrsOf(n, at);
  1048. children(n, na, pre);
  1049. };
  1050. const children = (n, at, pre) => {
  1051. const kids = Array.from(n.childNodes);
  1052. kids.forEach((c, k) => { markAt(n, k); inline(c, at, pre); });
  1053. markAt(n, kids.length);
  1054. };
  1055. const listEl = (el, at) => {
  1056. const t = el.tagName === 'OL' ? 'ol' : 'ul';
  1057. const start = t === 'ol' && el.hasAttribute('start') ? Number(el.getAttribute('start')) || 1 : 1;
  1058. const blk = block(t, [], { start });
  1059. if (!html && el.__mdO != null) blk.o = el.__mdO;
  1060. close();
  1061. const kids = Array.from(el.childNodes);
  1062. kids.forEach((c, k) => {
  1063. markAt(el, k);
  1064. if (c.nodeType === 1 && c.tagName === 'LI') {
  1065. open(blk);
  1066. const inner = Array.from(c.childNodes);
  1067. inner.forEach((d, j) => {
  1068. markAt(c, j);
  1069. if (d.nodeType === 1 && (d.tagName === 'UL' || d.tagName === 'OL')) {
  1070. // a nested list: its items become items of this one (one level only)
  1071. for (const li of d.children) { open(blk); children(li, at, false); }
  1072. } else if (d.nodeType === 1 && BLOCK_TAGS.has(d.tagName) && d.tagName !== 'LI') {
  1073. if (cur && cur.chars.length && cur.chars[cur.chars.length - 1].x !== NL) cur.chars.push({ x: NL, s: false, e: false, c: false, h: null, a: false });
  1074. children(d, attrsOf(d, at), false);
  1075. } else inline(d, at, false);
  1076. });
  1077. markAt(c, inner.length);
  1078. close();
  1079. } else if (c.nodeType === 3 && !c.data.trim()) {
  1080. // whitespace between items
  1081. } else {
  1082. open(blk);
  1083. inline(c, at, false);
  1084. close();
  1085. }
  1086. });
  1087. markAt(el, kids.length);
  1088. if (!blk.items.length && !html) { open(blk); close(); }
  1089. close();
  1090. };
  1091. const blockEl = (el, at) => {
  1092. const t = el.tagName;
  1093. if (t === 'HR') { close(); return; }
  1094. if (t === 'UL' || t === 'OL') { listEl(el, at); return; }
  1095. if (t === 'LI') { const fake = { tagName: 'UL', childNodes: [el], hasAttribute: () => false }; listEl(fake, at); return; }
  1096. if (t === 'PRE') {
  1097. const blk = block('code', []);
  1098. if (!html && el.__mdO != null) blk.o = el.__mdO;
  1099. open(blk);
  1100. if (html) pushText(textOfPre(el).replace(/\n$/, ''), {});
  1101. else children(el, {}, true);
  1102. close();
  1103. return;
  1104. }
  1105. if (/^H[1-6]$/.test(t)) {
  1106. const blk = block('h', [], { level: Number(t[1]) });
  1107. if (!html && el.__mdO != null) blk.o = el.__mdO;
  1108. open(blk);
  1109. children(el, html ? attrsOf(el, at) : at, false);
  1110. close();
  1111. return;
  1112. }
  1113. // P / DIV / … : a paragraph, unless it holds blocks itself
  1114. const hasBlocks = Array.from(el.children).some((c) => BLOCK_TAGS.has(c.tagName));
  1115. if (hasBlocks) {
  1116. close();
  1117. const na = html ? attrsOf(el, at) : at;
  1118. const kids = Array.from(el.childNodes);
  1119. kids.forEach((c, k) => {
  1120. markAt(el, k);
  1121. if (c.nodeType === 1 && BLOCK_TAGS.has(c.tagName)) { close(); blockEl(c, na); close(); }
  1122. else inline(c, na, false);
  1123. });
  1124. markAt(el, kids.length);
  1125. close();
  1126. return;
  1127. }
  1128. const blk = block('p', []);
  1129. if (!html && el.__mdO != null) blk.o = el.__mdO;
  1130. open(blk);
  1131. children(el, html ? attrsOf(el, at) : at, false);
  1132. close();
  1133. };
  1134. const kids = Array.from(root.childNodes);
  1135. kids.forEach((c, k) => {
  1136. markAt(root, k);
  1137. if (c.nodeType === 1 && BLOCK_TAGS.has(c.tagName)) { close(); blockEl(c, {}); close(); }
  1138. else if (c.nodeType === 3 && !html && c.data === '') { /* nothing */ }
  1139. else if (c.nodeType === 3 && html && !cur && !c.data.trim()) { /* whitespace between blocks */ }
  1140. else inline(c, {}, false);
  1141. });
  1142. markAt(root, kids.length);
  1143. close();
  1144. for (const m of marks) if (m.lin == null || m.lin < 0) m.lin = lin > 0 ? lin - 1 : 0; // after the last item
  1145. const out = doc.map((b) => { delete b.pushed; return b; });
  1146. return html ? out.filter((b) => b.t === 'code' || b.items.some((it) => lenOf(it) > 0)) : out;
  1147. }
  1148. function renderInline(parent, segs) {
  1149. let text = '';
  1150. for (const g of merge(segs)) {
  1151. let host = parent;
  1152. const wrap = (tag) => { const el = document.createElement(tag); host.appendChild(el); host = el; return el; };
  1153. if (g.h) {
  1154. const a = wrap('a');
  1155. a.setAttribute('href', g.h);
  1156. a.setAttribute('title', g.h);
  1157. if (g.a) a.setAttribute('data-auto', '');
  1158. } else if (g.c) wrap('code');
  1159. else {
  1160. if (g.s) wrap('strong');
  1161. if (g.e) wrap('em');
  1162. }
  1163. const parts = g.x.split(NL);
  1164. parts.forEach((p, k) => {
  1165. if (k) host.appendChild(document.createElement('br'));
  1166. if (p) host.appendChild(document.createTextNode(p));
  1167. });
  1168. text += g.x;
  1169. }
  1170. if (text === '' || text.endsWith(NL)) parent.appendChild(document.createElement('br'));
  1171. }
  1172. function renderDoc(doc) {
  1173. const frag = document.createDocumentFragment();
  1174. for (const b of doc) {
  1175. let el;
  1176. if (b.t === 'p') el = document.createElement('p');
  1177. else if (b.t === 'h') el = document.createElement('h' + Math.min(6, b.level));
  1178. else if (b.t === 'code') el = document.createElement('pre');
  1179. else el = document.createElement(b.t);
  1180. if (b.t === 'ol' && b.start !== 1) el.setAttribute('start', String(b.start));
  1181. if (b.t === 'ul' || b.t === 'ol') {
  1182. for (const it of b.items) { const li = document.createElement('li'); renderInline(li, it); el.appendChild(li); }
  1183. } else renderInline(el, b.items[0] || []);
  1184. el.__mdO = b.o;
  1185. frag.appendChild(el);
  1186. }
  1187. return frag;
  1188. }
  1189. // =============================================================================================
  1190. // 6. THE ELEMENT
  1191. // =============================================================================================
  1192. const ICON = {
  1193. link: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1"/></svg>',
  1194. ul: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6h11M9 12h11M9 18h11"/><circle cx="4.5" cy="6" r="1.2"/><circle cx="4.5" cy="12" r="1.2"/><circle cx="4.5" cy="18" r="1.2"/></svg>',
  1195. ol: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 6h10M10 12h10M10 18h10M4 4.5h1.5V9M3.5 9h3M3.5 14.5c.5-.8 2.8-.9 2.8.6 0 1.1-2.8 2.2-2.8 3.4h3"/></svg>',
  1196. code: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 7l-5 5 5 5M16 7l5 5-5 5"/></svg>',
  1197. block: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M8 10l-2 2 2 2M16 10l2 2-2 2"/></svg>',
  1198. undo: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 14L4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 0 12h-3"/></svg>',
  1199. redo: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 14l5-5-5-5"/><path d="M20 9H10a6 6 0 0 0 0 12h3"/></svg>',
  1200. };
  1201. const MAC = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || '');
  1202. const MOD = MAC ? 'Cmd' : 'Ctrl';
  1203. const TOOLS = [
  1204. { id: 'bold', label: '<b>B</b>', title: `Bold (${MOD}+B)` },
  1205. { id: 'italic', label: '<i>I</i>', title: `Italic (${MOD}+I)` },
  1206. { id: 'code', label: ICON.code, title: `Code (${MOD}+E)` },
  1207. { id: 'link', label: ICON.link, title: `Link (${MOD}+K)` },
  1208. { id: 'heading', label: 'H', title: `Heading: paragraph → H1 → H2 → H3 (${MOD}+Alt+1/2/3, ${MOD}+Alt+0)` },
  1209. { id: 'ul', label: ICON.ul, title: `Bulleted list (${MOD}+Shift+8)` },
  1210. { id: 'ol', label: ICON.ol, title: `Numbered list (${MOD}+Shift+7)` },
  1211. { id: 'codeblock', label: ICON.block, title: `Code block (${MOD}+Alt+C)` },
  1212. { id: 'undo', label: ICON.undo, title: `Undo (${MOD}+Z)` },
  1213. { id: 'redo', label: ICON.redo, title: `Redo (${MOD}+Shift+Z)` },
  1214. ];
  1215. const CSS = `
  1216. :host {
  1217. --_bg: var(--md-background, Field);
  1218. --_text: var(--md-text, FieldText);
  1219. --_strong: var(--md-text-strong, var(--_text));
  1220. --_muted: var(--md-text-muted, GrayText);
  1221. --_border: var(--md-border, GrayText);
  1222. --_accent: var(--md-accent, Highlight);
  1223. --_accent-text: var(--md-accent-text, HighlightText);
  1224. --_link: var(--md-link, var(--_accent));
  1225. --_code-bg: var(--md-code-background, rgba(127, 127, 127, .18));
  1226. --_toolbar-bg: var(--md-toolbar-background, transparent);
  1227. --_danger: var(--md-danger, #f44747);
  1228. --_radius: var(--md-radius, .4rem);
  1229. --_mono: var(--md-mono-font, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
  1230. display: block;
  1231. position: relative;
  1232. min-width: 0;
  1233. max-width: 100%;
  1234. font: var(--md-font, inherit);
  1235. color: var(--_text);
  1236. text-align: start;
  1237. }
  1238. :host([hidden]) { display: none; }
  1239. * { box-sizing: border-box; }
  1240. .box { display: flex; flex-direction: column; min-width: 0; background: var(--_bg); border: 1px solid var(--_border); border-radius: var(--_radius); }
  1241. :host(:focus-within) .box { outline: 2px solid var(--_accent); outline-offset: 1px; }
  1242. :host([invalid]) .box { border-color: var(--_danger); outline-color: var(--_danger); }
  1243. [part~="toolbar"] { display: flex; flex-wrap: wrap; gap: 1px; padding: 3px; background: var(--_toolbar-bg); border-bottom: 1px solid var(--_border); border-radius: var(--_radius) var(--_radius) 0 0; }
  1244. :host([toolbar="none"]) [part~="toolbar"] { display: none; }
  1245. [part~="toolbar"][hidden] { display: none; }
  1246. [part~="button"] { display: inline-flex; align-items: center; justify-content: center; min-width: 2rem; height: 2.1rem; padding: 0 .3rem; font: 600 .95rem/1 system-ui, sans-serif; color: var(--_text); background: transparent; border: 1px solid transparent; border-radius: calc(var(--_radius) * .8); cursor: pointer; }
  1247. [part~="button"]:hover { border-color: var(--_border); }
  1248. [part~="button"]:focus-visible { outline: 2px solid var(--_accent); outline-offset: -1px; }
  1249. [part~="button"][aria-pressed="true"] { color: var(--_accent-text); background: var(--_accent); }
  1250. [part~="button"]:disabled { opacity: .45; cursor: default; }
  1251. [part~="button"] svg { width: 1.15rem; height: 1.15rem; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
  1252. [part~="button"] svg circle { fill: currentColor; stroke: none; }
  1253. [part~="editor"] { min-height: var(--md-min-height, 6rem); padding: .5rem .7rem; outline: none; color: var(--_strong); white-space: pre-wrap; overflow-wrap: anywhere; word-break: normal; line-height: 1.5; cursor: text; }
  1254. [part~="editor"][hidden] { display: none; }
  1255. [part~="editor"] > :first-child { margin-top: 0; }
  1256. [part~="editor"] > :last-child { margin-bottom: 0; }
  1257. [part~="editor"] p { margin: 0 0 .5rem; }
  1258. [part~="editor"] h1, [part~="editor"] h2, [part~="editor"] h3, [part~="editor"] h4, [part~="editor"] h5, [part~="editor"] h6 { margin: .6rem 0 .35rem; line-height: 1.3; color: var(--_strong); }
  1259. [part~="editor"] h1 { font-size: var(--md-h1-size, 1.3em); }
  1260. [part~="editor"] h2 { font-size: var(--md-h2-size, 1.15em); }
  1261. [part~="editor"] h3, [part~="editor"] h4, [part~="editor"] h5, [part~="editor"] h6 { font-size: var(--md-h3-size, 1.02em); }
  1262. [part~="editor"] ul, [part~="editor"] ol { margin: 0 0 .5rem; padding-left: 1.4rem; }
  1263. [part~="editor"] ul { list-style-type: disc; }
  1264. [part~="editor"] ol { list-style-type: decimal; }
  1265. [part~="editor"] li + li { margin-top: .15rem; }
  1266. [part~="editor"] code { font: .88em/1.4 var(--_mono); padding: .05em .3em; background: var(--_code-bg); border-radius: .25rem; }
  1267. [part~="editor"] pre { margin: 0 0 .5rem; padding: .5rem .65rem; font: .88em/1.45 var(--_mono); white-space: pre-wrap; background: var(--_code-bg); border-radius: calc(var(--_radius) * .8); }
  1268. [part~="editor"] a { color: var(--_link); text-decoration: underline; text-underline-offset: .15em; }
  1269. [part~="editor"] strong { font-weight: 700; }
  1270. [part~="editor"] { position: relative; }
  1271. [part~="editor"].empty::before { content: attr(data-placeholder); position: absolute; left: .7rem; top: .5rem; right: .7rem; color: var(--_muted); pointer-events: none; }
  1272. [part~="link-form"] { display: flex; flex-wrap: wrap; align-items: center; gap: .35rem; padding: .4rem; border-top: 1px solid var(--_border); }
  1273. [part~="link-form"][hidden] { display: none; }
  1274. [part~="link-form"] input { flex: 1 1 12rem; min-width: 0; font: inherit; padding: .35rem .5rem; color: var(--_strong); background: transparent; border: 1px solid var(--_border); border-radius: calc(var(--_radius) * .8); }
  1275. [part~="link-form"] button { font: 600 .9rem/1 system-ui, sans-serif; padding: .45rem .7rem; color: var(--_text); background: transparent; border: 1px solid var(--_border); border-radius: calc(var(--_radius) * .8); cursor: pointer; }
  1276. [part~="link-form"] button.primary { color: var(--_accent-text); background: var(--_accent); border-color: var(--_accent); }
  1277. .linkmsg { flex-basis: 100%; margin: 0; font-size: .85rem; color: var(--_danger); }
  1278. .linkmsg:empty { display: none; }
  1279. [part~="source"] { display: block; box-sizing: border-box; width: 100%; min-height: var(--md-min-height, 6rem); margin: 0; padding: .5rem .7rem; font: .92em/1.5 var(--_mono); color: var(--_strong); background: transparent; border: 0; outline: none; resize: vertical; }
  1280. [part~="source"][hidden] { display: none; }
  1281. [part~="footer"] { display: flex; justify-content: flex-end; padding: .25rem .4rem; border-top: 1px solid var(--_border); }
  1282. :host([toolbar="none"]) [part~="footer"] { display: none; }
  1283. [part~="footer"] [part~="button"] { min-width: 0; height: auto; padding: .3rem .6rem; font: 600 .8rem/1 system-ui, sans-serif; color: var(--_muted); }
  1284. [part~="footer"] [part~="button"]:hover { color: var(--_text); }
  1285. [part~="footer"] [part~="button"][aria-pressed="true"] { color: var(--_accent-text); background: var(--_accent); }
  1286. .keep { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; opacity: 0; pointer-events: none; }
  1287. `;
  1288. const TEXTAREA_VALUE = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value');
  1289. const shadowSelectionOk = typeof ShadowRoot !== 'undefined' && 'getSelection' in ShadowRoot.prototype;
  1290. class MdEditor extends HTMLElement {
  1291. static get formAssociated() { return true; }
  1292. static get observedAttributes() { return ['placeholder', 'disabled', 'readonly', 'value', 'label']; }
  1293. constructor() {
  1294. super();
  1295. this._internals = this.attachInternals ? this.attachInternals() : null;
  1296. const root = this.attachShadow({ mode: 'open', delegatesFocus: false });
  1297. const style = document.createElement('style');
  1298. style.textContent = CSS;
  1299. const box = document.createElement('div');
  1300. box.className = 'box';
  1301. box.setAttribute('part', 'box');
  1302. // the toolbar (fixed markup of our own — no user text in it)
  1303. const bar = document.createElement('div');
  1304. bar.setAttribute('part', 'toolbar');
  1305. bar.setAttribute('role', 'toolbar');
  1306. bar.setAttribute('aria-label', 'Formatting');
  1307. this._buttons = {};
  1308. TOOLS.forEach((t, k) => {
  1309. const b = document.createElement('button');
  1310. b.type = 'button';
  1311. b.setAttribute('part', 'button ' + t.id);
  1312. b.dataset.tool = t.id;
  1313. b.title = t.title;
  1314. b.setAttribute('aria-label', t.title);
  1315. b.tabIndex = k === 0 ? 0 : -1;
  1316. if (!['link', 'undo', 'redo'].includes(t.id)) b.setAttribute('aria-pressed', 'false');
  1317. b.innerHTML = t.label;
  1318. bar.appendChild(b);
  1319. this._buttons[t.id] = b;
  1320. });
  1321. const ed = document.createElement('div');
  1322. ed.setAttribute('part', 'editor');
  1323. ed.setAttribute('role', 'textbox');
  1324. ed.setAttribute('aria-multiline', 'true');
  1325. ed.contentEditable = 'true';
  1326. ed.spellcheck = true;
  1327. // the link form
  1328. const lf = document.createElement('form');
  1329. lf.setAttribute('part', 'link-form');
  1330. lf.hidden = true;
  1331. lf.noValidate = true;
  1332. const li = document.createElement('input');
  1333. li.type = 'text';
  1334. li.setAttribute('inputmode', 'url');
  1335. li.autocomplete = 'off';
  1336. li.placeholder = 'https://…';
  1337. li.setAttribute('aria-label', 'Link address');
  1338. const mkBtn = (text, cls, type) => { const b = document.createElement('button'); b.type = type || 'button'; b.textContent = text; if (cls) b.className = cls; return b; };
  1339. const lOk = mkBtn('Apply', 'primary', 'submit');
  1340. const lRemove = mkBtn('Remove link');
  1341. const lCancel = mkBtn('Cancel');
  1342. const lMsg = document.createElement('p');
  1343. lMsg.className = 'linkmsg';
  1344. lMsg.setAttribute('role', 'alert');
  1345. lf.append(li, lOk, lRemove, lCancel, lMsg);
  1346. // the plain-text source view (raw Markdown, for copy/paste and direct editing)
  1347. const src = document.createElement('textarea');
  1348. src.setAttribute('part', 'source');
  1349. src.hidden = true;
  1350. src.spellcheck = false;
  1351. src.setAttribute('aria-label', 'Markdown source');
  1352. const footer = document.createElement('div');
  1353. footer.setAttribute('part', 'footer');
  1354. const srcBtn = document.createElement('button');
  1355. srcBtn.type = 'button';
  1356. srcBtn.setAttribute('part', 'button source-toggle');
  1357. srcBtn.setAttribute('aria-pressed', 'false');
  1358. srcBtn.textContent = 'Markdown source';
  1359. footer.appendChild(srcBtn);
  1360. // the enhanced textarea stays in the form, out of sight (a label click reaches it → the editor)
  1361. const keep = document.createElement('div');
  1362. keep.className = 'keep';
  1363. keep.appendChild(document.createElement('slot'));
  1364. box.append(bar, ed, src, lf, footer);
  1365. root.append(style, box, keep);
  1366. this._ed = ed;
  1367. this._bar = bar;
  1368. this._src = src;
  1369. this._srcBtn = srcBtn;
  1370. this._srcMode = false;
  1371. this._lf = lf; this._li = li; this._lRemove = lRemove; this._lMsg = lMsg;
  1372. this._slot = keep.firstChild;
  1373. this._ta = null;
  1374. this._doc = [block('p', [[]])];
  1375. this._orig = prepareOriginal('');
  1376. this._value = '';
  1377. this._undo = [];
  1378. this._redo = [];
  1379. this._lastSnap = null;
  1380. this._lastTypeAt = 0;
  1381. this._pending = null;
  1382. this._saved = null;
  1383. this._focusValue = null;
  1384. this._composing = false;
  1385. this._loaded = false;
  1386. ed.addEventListener('beforeinput', (e) => this._onBeforeInput(e));
  1387. // the editor's own input events stay inside: the page hears ONE input per change, from the
  1388. // textarea (enhance mode) or from <md-editor> itself, after the value is up to date
  1389. ed.addEventListener('input', (e) => { e.stopPropagation(); if (!e.isComposing && !this._composing) this._sync(e.inputType, e.data); });
  1390. ed.addEventListener('beforeinput', (e) => e.stopPropagation());
  1391. ed.addEventListener('compositionstart', () => { this._composing = true; });
  1392. ed.addEventListener('compositionend', (e) => {
  1393. this._composing = false;
  1394. const d = e.data || '';
  1395. setTimeout(() => this._sync(d ? 'insertText' : 'insertCompositionText', d ? d[d.length - 1] : null), 0);
  1396. });
  1397. ed.addEventListener('keydown', (e) => this._onKey(e));
  1398. ed.addEventListener('paste', (e) => this._onPaste(e));
  1399. ed.addEventListener('copy', (e) => this._onCopy(e, false));
  1400. ed.addEventListener('cut', (e) => this._onCopy(e, true));
  1401. ed.addEventListener('drop', (e) => this._onDrop(e));
  1402. ed.addEventListener('dragstart', (e) => e.preventDefault());
  1403. ed.addEventListener('focus', () => { this._focusValue = this.value; });
  1404. ed.addEventListener('blur', () => {
  1405. if (this._focusValue != null && this._focusValue !== this.value) this._fire('change');
  1406. this._focusValue = null;
  1407. });
  1408. // a click in the text must not reach a surrounding <label> (it would focus the hidden
  1409. // textarea and move the caret); Ctrl/Cmd+click opens a link
  1410. ed.addEventListener('click', (e) => {
  1411. e.preventDefault();
  1412. const a = e.target && e.target.closest && e.target.closest('a');
  1413. if (a && (e.ctrlKey || e.metaKey)) { const h = safeHref(a.getAttribute('href')); if (h) window.open(h, '_blank', 'noopener'); }
  1414. });
  1415. bar.addEventListener('mousedown', (e) => { if (e.target.closest('button')) e.preventDefault(); });
  1416. bar.addEventListener('click', (e) => { const b = e.target.closest('button'); if (b) this._tool(b.dataset.tool); });
  1417. bar.addEventListener('keydown', (e) => this._barKey(e));
  1418. lf.addEventListener('submit', (e) => { e.preventDefault(); this._linkApply(); });
  1419. lRemove.addEventListener('click', () => this._linkApply(true));
  1420. lCancel.addEventListener('click', () => this._linkClose());
  1421. lf.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); this._linkClose(); } });
  1422. srcBtn.addEventListener('click', () => this._toggleSource());
  1423. src.addEventListener('input', () => {
  1424. this._value = src.value;
  1425. this._dirty = true;
  1426. this.removeAttribute('invalid');
  1427. this._writeOut(true);
  1428. });
  1429. src.addEventListener('focus', () => { this._focusValue = src.value; });
  1430. src.addEventListener('blur', () => {
  1431. if (this._focusValue != null && this._focusValue !== src.value) this._fire('change');
  1432. this._focusValue = null;
  1433. });
  1434. src.addEventListener('keydown', (e) => {
  1435. const mod = MAC ? e.metaKey : e.ctrlKey;
  1436. if (mod && e.key === 'Enter') {
  1437. e.preventDefault();
  1438. const f = this.form || this.closest('form');
  1439. if (f) { if (f.requestSubmit) f.requestSubmit(); else f.submit(); }
  1440. }
  1441. });
  1442. this._slot.addEventListener('slotchange', () => this._adopt());
  1443. this._onSelChange = () => this._selChanged();
  1444. // <md-editor> is form-associated, so a surrounding <label> activates IT: the label's click
  1445. // arrives on the host itself (not from inside) → the caret goes into the editor
  1446. this.addEventListener('click', (e) => { if (e.composedPath()[0] === this) this.focus(); });
  1447. }
  1448. // ---- lifecycle --------------------------------------------------------------------------
  1449. connectedCallback() {
  1450. document.addEventListener('selectionchange', this._onSelChange);
  1451. this._adopt();
  1452. if (!this._loaded && !this._ta) {
  1453. this._loaded = true;
  1454. this._load(this.getAttribute('value') || '', false);
  1455. }
  1456. this._applyAttrs();
  1457. }
  1458. disconnectedCallback() { document.removeEventListener('selectionchange', this._onSelChange); }
  1459. attributeChangedCallback(name, old, v) {
  1460. if (name === 'value' && !this._ta && this._loaded && old !== v && !this._dirty) this._load(v || '', false);
  1461. this._applyAttrs();
  1462. }
  1463. formResetCallback() {
  1464. if (!this._ta) this._load(this.getAttribute('value') || '', true);
  1465. }
  1466. formDisabledCallback(disabled) { this._formDisabled = disabled; this._applyAttrs(); }
  1467. // the textarea child (enhance mode)
  1468. _adopt() {
  1469. const ta = Array.from(this.children).find((c) => c.tagName === 'TEXTAREA') || null;
  1470. if (ta === this._ta) return;
  1471. this._ta = ta;
  1472. if (!ta) return;
  1473. const self = this;
  1474. if (!ta.__mdEditor) {
  1475. Object.defineProperty(ta, 'value', {
  1476. configurable: true,
  1477. get() { return TEXTAREA_VALUE.get.call(this); },
  1478. set(v) { TEXTAREA_VALUE.set.call(this, v); if (this.__mdEditor && !this.__mdEditor._writing) this.__mdEditor._external(TEXTAREA_VALUE.get.call(this)); },
  1479. });
  1480. ta.addEventListener('invalid', (e) => { e.preventDefault(); const m = ta.__mdEditor; if (m) { m.setAttribute('invalid', ''); m.focus(); } });
  1481. ta.addEventListener('focus', () => { const m = ta.__mdEditor; if (m) m.focus(); });
  1482. // a script that writes the value and announces it with an input event
  1483. ta.addEventListener('input', () => { const m = ta.__mdEditor; if (m && !m._writing && TEXTAREA_VALUE.get.call(ta) !== m._value) m._external(TEXTAREA_VALUE.get.call(ta)); });
  1484. }
  1485. ta.__mdEditor = self;
  1486. ta.tabIndex = -1;
  1487. this._loaded = true;
  1488. this._load(TEXTAREA_VALUE.get.call(ta), false);
  1489. this._applyAttrs();
  1490. }
  1491. _applyAttrs() {
  1492. const ta = this._ta;
  1493. const off = this.hasAttribute('disabled') || this.hasAttribute('readonly') || !!this._formDisabled || (ta && (ta.disabled || ta.readOnly));
  1494. this._ed.contentEditable = off ? 'false' : 'true';
  1495. this._ed.setAttribute('aria-disabled', off ? 'true' : 'false');
  1496. for (const b of Object.values(this._buttons)) b.disabled = !!off;
  1497. this._histButtons();
  1498. const hardOff = this.hasAttribute('disabled') || !!this._formDisabled || (ta && ta.disabled);
  1499. this._srcBtn.disabled = !!hardOff;
  1500. this._src.disabled = !!hardOff;
  1501. this._src.readOnly = !!off;
  1502. const ph = this.getAttribute('placeholder') || (ta && ta.getAttribute('placeholder')) || '';
  1503. this._ed.dataset.placeholder = ph;
  1504. this._src.placeholder = ph;
  1505. let label = this.getAttribute('label') || (ta && ta.getAttribute('aria-label')) || '';
  1506. const labels = (this._internals && this._internals.labels && this._internals.labels.length ? this._internals.labels : ta && ta.labels) || [];
  1507. if (!label && labels[0]) label = Array.from(labels[0].childNodes).filter((n) => n.nodeType === 3).map((n) => n.data).join(' ').trim();
  1508. if (label) this._ed.setAttribute('aria-label', label);
  1509. if (ta && ta.required) this._ed.setAttribute('aria-required', 'true');
  1510. const rows = ta ? Number(ta.getAttribute('rows')) : Number(this.getAttribute('rows'));
  1511. if (rows > 0) { this._ed.style.minHeight = `calc(${rows} * 1.5em + 1rem)`; this._src.style.minHeight = this._ed.style.minHeight; }
  1512. }
  1513. // ---- value ------------------------------------------------------------------------------
  1514. get value() { return this._value; }
  1515. set value(v) { this._load(v == null ? '' : String(v), true); this._writeOut(false); }
  1516. focus(opts) {
  1517. this._ed.focus(opts);
  1518. if (!this._rangeLin()) {
  1519. const s = this._saved;
  1520. const n = docLen(this._doc);
  1521. if (s) this._setSel(Math.min(s.a, n), Math.min(s.b, n)); else this._setSel(n, n);
  1522. }
  1523. }
  1524. get form() { return this._ta ? this._ta.form : this._internals ? this._internals.form : null; }
  1525. get name() { return this._ta ? this._ta.name : this.getAttribute('name'); }
  1526. _load(md, keepUndo) {
  1527. const text = md == null ? '' : String(md);
  1528. if (keepUndo && this._lastSnap) this._pushUndo(this._lastSnap);
  1529. this._orig = prepareOriginal(text);
  1530. this._doc = edDoc(clone(this._orig.doc));
  1531. this._value = text;
  1532. this._dirty = false;
  1533. this._pending = null;
  1534. this._render(true);
  1535. if (this._src) this._src.value = text;
  1536. this._lastSnap = this._snap();
  1537. if (!keepUndo) { this._undo = []; this._redo = []; }
  1538. if (this._internals && !this._ta) this._internals.setFormValue(text);
  1539. }
  1540. _external(v) {
  1541. if (v === this._value) return;
  1542. const focused = this._hasFocus();
  1543. this._load(v, true);
  1544. if (focused) this._setSel(docLen(this._doc), docLen(this._doc));
  1545. }
  1546. _commit() {
  1547. const md = serialize(this._doc, this._orig);
  1548. this._dirty = true;
  1549. if (md === this._value) return;
  1550. this._value = md;
  1551. this.removeAttribute('invalid');
  1552. this._writeOut(true);
  1553. }
  1554. _writeOut(announce) {
  1555. if (this._ta) {
  1556. this._writing = true;
  1557. try { TEXTAREA_VALUE.set.call(this._ta, this._value); } finally { this._writing = false; }
  1558. if (announce) this._ta.dispatchEvent(new Event('input', { bubbles: true }));
  1559. } else {
  1560. if (this._internals) this._internals.setFormValue(this._value);
  1561. if (announce) this._fire('input');
  1562. }
  1563. }
  1564. _fire(type) {
  1565. if (this._ta) this._ta.dispatchEvent(new Event(type, { bubbles: true }));
  1566. else this.dispatchEvent(new Event(type, { bubbles: true }));
  1567. }
  1568. // ---- rendering + selection ----------------------------------------------------------------
  1569. _render(force) {
  1570. const frag = renderDoc(this._doc);
  1571. if (!force) {
  1572. const probe = document.createElement('div');
  1573. probe.appendChild(frag.cloneNode(true));
  1574. if (probe.innerHTML === this._ed.innerHTML) {
  1575. // same markup: only hand the blocks their origin again
  1576. Array.from(this._ed.children).forEach((el, k) => { if (this._doc[k]) el.__mdO = this._doc[k].o; });
  1577. this._placeholder();
  1578. return false;
  1579. }
  1580. }
  1581. this._ed.replaceChildren(frag);
  1582. this._placeholder();
  1583. return true;
  1584. }
  1585. _placeholder() {
  1586. const d = this._doc;
  1587. const empty = d.length === 1 && d[0].t === 'p' && lenOf(d[0].items[0]) === 0;
  1588. this._ed.classList.toggle('empty', empty);
  1589. }
  1590. _selection() {
  1591. if (shadowSelectionOk) return this.shadowRoot.getSelection();
  1592. return document.getSelection();
  1593. }
  1594. _hasFocus() { return this.shadowRoot.activeElement === this._ed; }
  1595. _rawRange() {
  1596. const sel = this._selection();
  1597. if (!sel) return null;
  1598. if (!shadowSelectionOk && sel.getComposedRanges) {
  1599. const rs = sel.getComposedRanges({ shadowRoots: [this.shadowRoot] });
  1600. if (!rs.length) return null;
  1601. return { sc: rs[0].startContainer, so: rs[0].startOffset, ec: rs[0].endContainer, eo: rs[0].endOffset };
  1602. }
  1603. if (!sel.rangeCount) return null;
  1604. const r = sel.getRangeAt(0);
  1605. return { sc: r.startContainer, so: r.startOffset, ec: r.endContainer, eo: r.endOffset };
  1606. }
  1607. // the selection as linear model positions { a, b } (read from the current DOM), or null
  1608. _rangeLin(fromDom) {
  1609. const r = this._rawRange();
  1610. if (!r || !this._ed.contains(r.sc) || !this._ed.contains(r.ec)) return null;
  1611. const m1 = { node: r.sc, off: r.so }, m2 = { node: r.ec, off: r.eo };
  1612. const raw = readDom(this._ed, { marks: [m1, m2] });
  1613. const res = { a: Math.min(m1.lin, m2.lin), b: Math.max(m1.lin, m2.lin), doc: raw };
  1614. return fromDom ? res : { a: res.a, b: res.b };
  1615. }
  1616. _point(lin) {
  1617. const p = fromLin(this._doc, lin);
  1618. const bel = this._ed.children[p.b];
  1619. if (!bel) return [this._ed, this._ed.childNodes.length];
  1620. const iel = (bel.tagName === 'UL' || bel.tagName === 'OL') ? bel.children[p.i] || bel : bel;
  1621. let o = p.o;
  1622. const walker = document.createTreeWalker(iel, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
  1623. let n = walker.nextNode();
  1624. let lastBr = null;
  1625. while (n) {
  1626. if (n.nodeType === 3) {
  1627. if (o <= n.data.length) return [n, o];
  1628. o -= n.data.length;
  1629. } else if (n.tagName === 'BR') {
  1630. if (o === 0) return [n.parentNode, Array.prototype.indexOf.call(n.parentNode.childNodes, n)];
  1631. o -= 1;
  1632. lastBr = n;
  1633. }
  1634. n = walker.nextNode();
  1635. }
  1636. if (lastBr && lastBr === iel.lastChild) return [iel, iel.childNodes.length - 1];
  1637. return [iel, iel.childNodes.length];
  1638. }
  1639. _setSel(a, b) {
  1640. const sel = this._selection() || document.getSelection();
  1641. const [n1, o1] = this._point(a);
  1642. const [n2, o2] = this._point(b == null ? a : b);
  1643. try { sel.setBaseAndExtent(n1, o1, n2, o2); } catch (err) { /* detached */ }
  1644. this._saved = { a, b: b == null ? a : b };
  1645. }
  1646. _snap() {
  1647. const r = this._saved || { a: 0, b: 0 };
  1648. return { doc: clone(this._doc), a: r.a, b: r.b };
  1649. }
  1650. _pushUndo(s) {
  1651. this._undo.push(s);
  1652. if (this._undo.length > 200) this._undo.shift();
  1653. this._redo = [];
  1654. }
  1655. // ---- the one path every change takes -----------------------------------------------------
  1656. _apply(doc, a, b) {
  1657. this._doc = edDoc(doc);
  1658. this._render(true);
  1659. const n = docLen(this._doc);
  1660. a = Math.min(Math.max(0, a), n);
  1661. b = b == null ? a : Math.min(Math.max(0, b), n);
  1662. if (this._hasFocus() || document.activeElement === this) this._setSel(a, b);
  1663. else this._saved = { a, b };
  1664. this._commit();
  1665. this._lastSnap = this._snap();
  1666. this._updateBar();
  1667. }
  1668. _op(fn) {
  1669. if (this._ed.contentEditable !== 'true') return;
  1670. const r = this._rangeLin() || this._saved || { a: docLen(this._doc), b: docLen(this._doc) };
  1671. // undo brings back the text AND the selection this action was made on
  1672. this._pushUndo({ doc: (this._lastSnap || this._snap()).doc, a: r.a, b: r.b });
  1673. this._lastTypeAt = 0;
  1674. const doc = clone(this._doc);
  1675. const res = fn(doc, r.a, r.b);
  1676. this._apply(res.doc || doc, res.a, res.b);
  1677. }
  1678. // after the browser changed the DOM itself (typing, native deletes): read it back, apply the
  1679. // input rules, normalise, re-render only if the markup is not what the model renders
  1680. _sync(inputType, data) {
  1681. const r = this._rangeLin(true);
  1682. const doc0 = r ? r.doc : readDom(this._ed, {});
  1683. let a = r ? r.a : 0, b = r ? r.b : 0;
  1684. const now = Date.now();
  1685. const typing = inputType === 'insertText' || inputType === 'insertCompositionText';
  1686. if (!(typing && now - this._lastTypeAt < 1500) && this._lastSnap) this._pushUndo(this._lastSnap);
  1687. this._lastTypeAt = typing ? now : 0;
  1688. let doc = edDoc(doc0);
  1689. // a pending style (toolbar / shortcut with a collapsed caret, or after an input rule)
  1690. if (this._pending && typing && a === b && a > this._pending.at) {
  1691. const pd = this._pending;
  1692. eachInRange(doc, pd.at, a, (chars, f, t) => {
  1693. for (let k = f; k < t; k++) { const ch = chars[k]; if (ch.h) continue; ch.s = pd.s; ch.e = pd.e; ch.c = pd.c; }
  1694. });
  1695. pd.at = a;
  1696. }
  1697. if (inputType === 'insertText' && data && a === b) {
  1698. const res = this._rules(doc, a, data);
  1699. if (res) {
  1700. this._pushUndo(this._snapOf(doc, a));
  1701. doc = res.doc;
  1702. a = b = res.a;
  1703. this._pending = res.pending ? Object.assign({ at: a }, res.pending) : this._pending;
  1704. }
  1705. }
  1706. this._doc = edDoc(doc);
  1707. const changed = this._render(false);
  1708. if (changed) this._setSel(a, b);
  1709. else this._saved = { a, b };
  1710. this._commit();
  1711. this._lastSnap = this._snap();
  1712. this._updateBar();
  1713. }
  1714. _snapOf(doc, a) { return { doc: clone(edDoc(doc)), a, b: a }; }
  1715. // ---- input rules: Markdown typed → formatted ---------------------------------------------
  1716. _rules(doc0, a, data) {
  1717. const doc = clone(doc0);
  1718. const p = fromLin(doc, a);
  1719. const blk = doc[p.b];
  1720. if (blk.t === 'code') return null;
  1721. const chars = toChars(blk.items[p.i]);
  1722. const before = chars.slice(0, p.o);
  1723. const text = before.map((c) => c.x).join('');
  1724. // typed markers must be plain text (a bare URL being typed counts as text)
  1725. const plain = (f, t) => before.slice(f, t).every((c) => !c.c && (!c.h || c.a));
  1726. let m;
  1727. if (blk.t === 'p' && !text.includes(NL) && plain(0, text.length)) {
  1728. const rest = fromChars(chars.slice(p.o));
  1729. const base = a - p.o;
  1730. if ((m = /^(#{1,6}) $/.exec(text))) { doc[p.b] = block('h', [rest], { level: m[1].length }); return { doc, a: base }; }
  1731. if (/^[-*+] $/.test(text)) { doc[p.b] = block('ul', [rest]); return { doc, a: base }; }
  1732. if ((m = /^(\d{1,9})[.)] $/.exec(text))) { doc[p.b] = block('ol', [rest], { start: Number(m[1]) }); return { doc, a: base }; }
  1733. if (/^(```|~~~)$/.test(text)) { doc[p.b] = block('code', [fromChars(chars.slice(p.o).map((c) => Object.assign(c, { s: false, e: false, c: false, h: null })))]); return { doc, a: base }; }
  1734. }
  1735. const line0 = text.lastIndexOf(NL) + 1;
  1736. const line = text.slice(line0);
  1737. const next = chars[p.o] ? chars[p.o].x : '';
  1738. const apply = (start, openLen, content, closeLen, attr, href) => {
  1739. const s0 = line0 + start;
  1740. if (!plain(s0, text.length)) return null;
  1741. const inner = before.slice(s0 + openLen, text.length - closeLen);
  1742. for (const c of inner) {
  1743. if (c.a) { c.h = null; c.a = false; }
  1744. if (attr === 'c') Object.assign(c, { c: true, s: false, e: false });
  1745. else if (attr === 'h') Object.assign(c, { h: href, a: false, s: false, e: false, c: false });
  1746. else c[attr] = true;
  1747. }
  1748. const out = before.slice(0, s0).concat(inner, chars.slice(p.o));
  1749. blk.items[p.i] = fromChars(out);
  1750. const na = a - openLen - closeLen - (text.length - closeLen - (s0 + openLen) - inner.length);
  1751. return { doc, a: na, pending: { s: false, e: false, c: false } };
  1752. };
  1753. if (data === '`' && (m = /(^|[^`\\])`([^`]+)`$/.exec(line)) && m[2].trim()) {
  1754. return apply(m.index + m[1].length, 1, m[2], 1, 'c');
  1755. }
  1756. // * and ** only open at the start of a word (2*3*4 stays literal)
  1757. if (data === '*' && (m = /(^|[^\w*\\])\*\*([^*\s](?:[^*]*[^*\s])?)\*\*$/.exec(line))) {
  1758. return apply(m.index + m[1].length, 2, m[2], 2, 's');
  1759. }
  1760. if (data === '*' && (m = /(^|[^\w*\\])\*([^*\s](?:[^*]*[^*\s])?)\*$/.exec(line))) {
  1761. return apply(m.index + m[1].length, 1, m[2], 1, 'e');
  1762. }
  1763. if (data === '_' && !isAlnumC(next.charCodeAt(0) || 32) && (m = /(^|[^\w_\\])__([^_\s](?:[^_]*[^_\s])?)__$/.exec(line))) {
  1764. return apply(m.index + m[1].length, 2, m[2], 2, 's');
  1765. }
  1766. if (data === '_' && !isAlnumC(next.charCodeAt(0) || 32) && (m = /(^|[^\w_\\])_([^_\s](?:[^_]*[^_\s])?)_$/.exec(line))) {
  1767. return apply(m.index + m[1].length, 1, m[2], 1, 'e');
  1768. }
  1769. if (data === ')' && (m = /\[([^\[\]\n]+)\]\(([^()\s]+)\)$/.exec(line))) {
  1770. const h = safeHref(m[2]);
  1771. if (h) {
  1772. const res = apply(m.index, 1, m[1], m[2].length + 3, 'h', h);
  1773. if (res) res.pending = null;
  1774. return res;
  1775. }
  1776. }
  1777. return null;
  1778. }
  1779. // ---- browser input events --------------------------------------------------------------------
  1780. _onBeforeInput(e) {
  1781. if (this._ed.contentEditable !== 'true') { e.preventDefault(); return; }
  1782. const t = e.inputType;
  1783. const r = this._rangeLin();
  1784. const multi = r && r.a !== r.b && (() => { const P = fromLin(this._doc, r.a), Q = fromLin(this._doc, r.b); return P.b !== Q.b || P.i !== Q.i; })();
  1785. const cancel = () => { e.preventDefault(); };
  1786. switch (t) {
  1787. case 'insertParagraph':
  1788. cancel();
  1789. this._op((doc, a, b) => { delRange(doc, a, b); return { a: opEnter(doc, a) }; });
  1790. return;
  1791. case 'insertLineBreak':
  1792. cancel();
  1793. this._op((doc, a, b) => {
  1794. delRange(doc, a, b);
  1795. const p = fromLin(doc, a);
  1796. if (doc[p.b].t === 'h') return { a: opEnter(doc, a) };
  1797. return { a: insChars(doc, a, plainChars(NL)) };
  1798. });
  1799. return;
  1800. case 'historyUndo': cancel(); this.undo(); return;
  1801. case 'historyRedo': cancel(); this.redo(); return;
  1802. case 'formatBold': cancel(); this._toggle('s'); return;
  1803. case 'formatItalic': cancel(); this._toggle('e'); return;
  1804. case 'insertFromDrop': case 'deleteByDrag': case 'insertFromPaste': case 'insertFromPasteAsQuotation': case 'insertFromYank':
  1805. cancel(); return;
  1806. default:
  1807. if (t.startsWith('format')) { cancel(); return; }
  1808. }
  1809. if (!r) return;
  1810. if (t.startsWith('delete')) {
  1811. if (r.a !== r.b) {
  1812. if (multi) { cancel(); this._op((doc, a, b) => { delRange(doc, a, b); return { a }; }); }
  1813. return;
  1814. }
  1815. const p = fromLin(this._doc, r.a);
  1816. const L = lenOf(this._doc[p.b].items[p.i]);
  1817. const backward = t.includes('Backward');
  1818. if (backward && p.o === 0 && (p.b > 0 || p.i > 0 || this._doc[p.b].t !== 'p')) {
  1819. cancel();
  1820. this._op((doc, a) => ({ a: opBackspaceAtStart(doc, a) }));
  1821. } else if (!backward && p.o === L) {
  1822. cancel();
  1823. this._op((doc, a) => ({ a: opDeleteAtEnd(doc, a) }));
  1824. } else if (backward && p.o === 0) {
  1825. cancel();
  1826. }
  1827. return;
  1828. }
  1829. if ((t === 'insertText' || t === 'insertReplacementText') && multi) {
  1830. cancel();
  1831. const data = e.data != null ? e.data : (e.dataTransfer && e.dataTransfer.getData('text/plain')) || '';
  1832. this._op((doc, a, b) => { delRange(doc, a, b); return { a: insChars(doc, a, plainChars(data)) }; });
  1833. }
  1834. }
  1835. _onKey(e) {
  1836. const mod = MAC ? e.metaKey : e.ctrlKey;
  1837. if (this._ed.contentEditable !== 'true') return;
  1838. if (mod && e.key === 'Enter') {
  1839. e.preventDefault();
  1840. const f = this.form || this.closest('form');
  1841. if (f) { if (f.requestSubmit) f.requestSubmit(); else f.submit(); }
  1842. return;
  1843. }
  1844. if (!mod) return;
  1845. const k = e.key.toLowerCase();
  1846. let act = null;
  1847. if (!e.altKey && !e.shiftKey && k === 'b') act = () => this._toggle('s');
  1848. else if (!e.altKey && !e.shiftKey && k === 'i') act = () => this._toggle('e');
  1849. else if (!e.altKey && !e.shiftKey && k === 'e') act = () => this._toggle('c');
  1850. else if (!e.altKey && !e.shiftKey && k === 'k') act = () => this._linkOpen();
  1851. else if (!e.altKey && !e.shiftKey && k === 'z') act = () => this.undo();
  1852. else if (!e.altKey && ((e.shiftKey && k === 'z') || (!e.shiftKey && k === 'y'))) act = () => this.redo();
  1853. else if (e.altKey && !e.shiftKey && ['0', '1', '2', '3'].includes(e.key)) act = () => this._heading(Number(e.key));
  1854. else if (e.altKey && !e.shiftKey && e.code === 'KeyC') act = () => this._block('code');
  1855. else if (e.shiftKey && !e.altKey && e.code === 'Digit8') act = () => this._block('ul');
  1856. else if (e.shiftKey && !e.altKey && e.code === 'Digit7') act = () => this._block('ol');
  1857. if (act) { e.preventDefault(); act(); }
  1858. }
  1859. _barKey(e) {
  1860. if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight' && e.key !== 'Home' && e.key !== 'End') return;
  1861. const bs = Array.from(this._bar.querySelectorAll('button'));
  1862. let k = bs.indexOf(this.shadowRoot.activeElement);
  1863. if (k < 0) return;
  1864. e.preventDefault();
  1865. k = e.key === 'Home' ? 0 : e.key === 'End' ? bs.length - 1 : (k + (e.key === 'ArrowRight' ? 1 : -1) + bs.length) % bs.length;
  1866. bs.forEach((b, j) => { b.tabIndex = j === k ? 0 : -1; });
  1867. bs[k].focus();
  1868. }
  1869. // ---- paste / copy / drop ---------------------------------------------------------------
  1870. _fragmentOf(dt, inCode) {
  1871. const htmlData = dt.getData('text/html');
  1872. const text = dt.getData('text/plain');
  1873. if (inCode) return { code: text || (htmlData ? new DOMParser().parseFromString(htmlData, 'text/html').body.textContent : '') };
  1874. if (htmlData && htmlData.trim()) {
  1875. // inert: a parsed document runs no script and loads nothing; only the subset is read
  1876. const d = new DOMParser().parseFromString(htmlData, 'text/html');
  1877. return { doc: readDom(d.body, { html: true }) };
  1878. }
  1879. const pb = [];
  1880. const ranges = [];
  1881. for (const b of markdownBlocks(text || '', ranges)) pb.push(fromParsed(b, ranges[pb.length].level));
  1882. return { doc: pb };
  1883. }
  1884. _insertFragment(frag) {
  1885. this._op((doc, a, b) => {
  1886. delRange(doc, a, b);
  1887. if (frag.code != null) return { a: insChars(doc, a, plainChars(frag.code.replace(/\r\n?/g, NL))) };
  1888. const blocks = edDoc(frag.doc || []).filter((x) => x.t === 'code' || x.items.some((it) => lenOf(it) > 0));
  1889. if (!blocks.length) return { a };
  1890. if (blocks.length === 1 && blocks[0].t === 'p') return { a: insChars(doc, a, toChars(blocks[0].items[0])) };
  1891. const p = fromLin(doc, a);
  1892. const blk = doc[p.b];
  1893. // split the block at the caret and put the pasted blocks between the halves
  1894. const chars = toChars(blk.items[p.i]);
  1895. const headItems = blk.items.slice(0, p.i).concat([fromChars(chars.slice(0, p.o))]);
  1896. const tailItems = [fromChars(chars.slice(p.o))].concat(blk.items.slice(p.i + 1));
  1897. const head = Object.assign({}, blk, { items: headItems });
  1898. const tail = Object.assign({}, blk, { items: tailItems, o: -1 });
  1899. const keep = (x) => x.items.some((it) => lenOf(it) > 0);
  1900. const repl = [].concat(keep(head) ? [head] : [], blocks, keep(tail) || !keep(head) ? [tail] : []);
  1901. doc.splice(p.b, 1, ...repl);
  1902. // the caret goes to the end of the last pasted block
  1903. const lastB = p.b + (keep(head) ? 1 : 0) + blocks.length - 1;
  1904. const lastI = doc[lastB].items.length - 1;
  1905. return { a: toLin(doc, { b: lastB, i: lastI, o: lenOf(doc[lastB].items[lastI]) }) };
  1906. });
  1907. }
  1908. _inCode() {
  1909. const r = this._rangeLin() || this._saved;
  1910. if (!r) return false;
  1911. return this._doc[fromLin(this._doc, r.a).b].t === 'code';
  1912. }
  1913. _onPaste(e) {
  1914. e.preventDefault();
  1915. if (!e.clipboardData || this._ed.contentEditable !== 'true') return;
  1916. this._insertFragment(this._fragmentOf(e.clipboardData, this._inCode()));
  1917. }
  1918. _onDrop(e) {
  1919. e.preventDefault();
  1920. if (!e.dataTransfer || this._ed.contentEditable !== 'true') return;
  1921. let pos = null;
  1922. if (document.caretPositionFromPoint) {
  1923. try { pos = document.caretPositionFromPoint(e.clientX, e.clientY, { shadowRoots: [this.shadowRoot] }); } catch (err) { pos = null; }
  1924. }
  1925. this._ed.focus();
  1926. if (pos && this._ed.contains(pos.offsetNode)) {
  1927. const sel = this._selection() || document.getSelection();
  1928. try { sel.setBaseAndExtent(pos.offsetNode, pos.offset, pos.offsetNode, pos.offset); } catch (err) { /* keep */ }
  1929. }
  1930. this._insertFragment(this._fragmentOf(e.dataTransfer, this._inCode()));
  1931. }
  1932. _onCopy(e, cut) {
  1933. const r = this._rangeLin();
  1934. if (!r || r.a === r.b || !e.clipboardData) return;
  1935. e.preventDefault();
  1936. const part = this._slice(r.a, r.b);
  1937. const md = serialize(part, null);
  1938. const div = document.createElement('div');
  1939. div.appendChild(renderDoc(part));
  1940. e.clipboardData.setData('text/plain', md);
  1941. e.clipboardData.setData('text/html', div.innerHTML);
  1942. if (cut) this._op((doc, a, b) => { delRange(doc, a, b); return { a }; });
  1943. }
  1944. _slice(a, b) {
  1945. const doc = clone(this._doc);
  1946. delRange(doc, b, docLen(doc));
  1947. delRange(doc, 0, a);
  1948. return edDoc(doc.map((x) => Object.assign(x, { o: -1 })));
  1949. }
  1950. // ---- toolbar actions ---------------------------------------------------------------------
  1951. _tool(id) {
  1952. if (this._ed.contentEditable !== 'true') return;
  1953. if (id === 'bold') this._toggle('s');
  1954. else if (id === 'italic') this._toggle('e');
  1955. else if (id === 'code') this._toggle('c');
  1956. else if (id === 'link') this._linkOpen();
  1957. else if (id === 'heading') {
  1958. const r = this._rangeLin() || this._saved || { a: 0 };

Only the first lines are shown.

Branches

Latest commits

  • d3db6139tickets: Hybriel master 190aa11d (fc838894 GC correctness, #126 closure scopes, #127); gate 249/0, connect 60/0mre
  • bce182e3tickets: Hybriel master 7eea0d32 (#126 memory, #48 lambda copies its argument); migrate.hl lambdas take &logmre
  • 4137be0fantcolony#40: mission references point to the moved missionsmre
  • 9bfba36aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • c7bd2645tickets: Hybriel master 73267707 (#122 fixed); compactNow workaround removed (#110 covered)mre
  • 2ab91ee9tickets: gate checks rows appear once (session sync); re-vendor to ff51cf46 stopped on hybriel#122, stays 837fe120mre
  • e01c2b1dtickets#24: installable app (manifest, service worker, offline list), own icon; gate waits for the hello's pongmre
  • 752fbb7fdeploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • 38bdd5e4deploy.sh: never send .git or .gitignore to Byrodinmre
  • f12fa1bcState of 2026-09-27, before the move to gitoriamre