tickets
All repositories: gitoria
99.0 KB
// md-editor.js — <md-editor>: a Markdown field that is edited VISUALLY (formatted while typing)// and whose value stays PLAIN MARKDOWN. worldapi-components (ticket tickets.worldapi.org#12,// concept accepted in #14). Plain JS, no build step, no dependencies, framework independent.//// <script src="md-editor.js"></script>// <md-editor><textarea name="summary" rows="4"></textarea></md-editor> ENHANCES a textarea// <md-editor name="summary" value="**hi**"></md-editor> or stands alone//// ENHANCE MODE (the normal case): the textarea stays in the form and keeps the Markdown — the// editor writes every change into it (+ an `input` event on it), and follows it when a script// sets `textarea.value` (a framework clearing the field after a send). Without JS the page shows// the plain textarea as before. STANDALONE: a form-associated element (`name`, `value`).//// THE MARKDOWN SUBSET is exactly the one tickets' renderer understands (tickets.worldapi.org// markdown.hl — the parser below is a line-by-line port of it): `#`…`######` headings, `-`/`*`/`+`// and `1.`/`1)` lists (one level), ``` / ~~~ fenced code, paragraphs whose single line breaks are// kept, `code`, [text](url), <https://…>, bare http(s) URLs, *em* _em_ **strong** __strong__// ***both***, backslash escapes. Nothing else exists in the editor (no quotes, tables, images,// nesting, HTML).//// LOSSLESS: a block the user did not change is written back byte for byte (its original source);// a changed block is written in a canonical form that is VERIFIED against the parser (it must// parse back to exactly what the editor shows, else a safer escaping/marker variant is taken).// SAFE: the editor's DOM is built only with createElement/text nodes; hrefs pass safeHref// (http(s):, mailto:, /path, #anchor); pasted HTML is parsed in an inert document and reduced to// the subset — no HTML from outside ever reaches the page.//// See README.md (attributes, events, keyboard, restyling with --md-* variables and ::part()).(() => {'use strict';if (typeof window === 'undefined' || !window.customElements || customElements.get('md-editor')) return;const NL = '\n';// =============================================================================================// 1. THE SUBSET — port of tickets.worldapi.org/markdown.hl (keep in step with it)// =============================================================================================const isSpaceC = (c) => c === 32 || c === 9 || c === 10 || c === 13;const isAlnumC = (c) => (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c > 127;const isPunctC = (c) => (c >= 33 && c <= 47) || (c >= 58 && c <= 64) || (c >= 91 && c <= 96) || (c >= 123 && c <= 126);function safeHref(u) {if (u == null) return null;const t = String(u).trim();if (t === '' || t.length > 2000) return null;for (let i = 0; i < t.length; i++) {const c = t.charCodeAt(i);if (c <= 32 || c === 127 || c === 34 || c === 39 || c === 60 || c === 62 || c === 96 || c === 92) return null;}const low = t.toLowerCase();if (low.startsWith('https://') && t.length > 8) return t;if (low.startsWith('http://') && t.length > 7) return t;if (low.startsWith('mailto:') && t.length > 7) return t;if (t.startsWith('/') && !t.startsWith('//')) return t;if (t.startsWith('#')) return t;return null;}const isUrlStart = (s, i) => {const low = s.slice(i, i + 8).toLowerCase();return low.startsWith('https://') || low.startsWith('http://');};const isUrlStop = (c) => isSpaceC(c) || c === 60 || c === 62 || c === 34 || c === 96;function urlEnd(s, i) {let end = i;while (end < s.length && !isUrlStop(s.charCodeAt(end))) end++;let trimming = true;while (trimming && end > i) {const c = s.charCodeAt(end - 1);if (c === 46 || c === 44 || c === 59 || c === 58 || c === 33 || c === 63 || c === 39 || c === 42 || c === 95) {end--;} else if (c === 41) {let opens = 0, closes = 0;for (let k = i; k < end; k++) {if (s.charCodeAt(k) === 40) opens++;if (s.charCodeAt(k) === 41) closes++;}if (closes > opens) end--; else trimming = false;} else {trimming = false;}}return end;}function linkAt(s, i) {let j = i + 1, found = -1;while (j < s.length && found < 0) {const c = s.charCodeAt(j);if (c === 91) return null;if (c === 93) found = j;j++;}if (found < 0 || found === i + 1) return null;if (found + 1 >= s.length || s.charCodeAt(found + 1) !== 40) return null;const k = s.indexOf(')', found + 2);if (k < 0) return null;let target = s.slice(found + 2, k).trim();const sp = target.indexOf(' ');if (sp > 0) target = target.slice(0, sp);return { label: s.slice(i + 1, found), href: safeHref(target), end: k + 1 };}const textSpan = (t, strong, em) => ({ text: t, isText: !strong && !em, isStrong: strong && !em, isEm: em && !strong, isStrongEm: strong && em, isCode: false, isLink: false, href: '' });const codeSpan = (t) => ({ text: t, isText: false, isStrong: false, isEm: false, isStrongEm: false, isCode: true, isLink: false, href: '' });const linkSpan = (t, h) => ({ text: t, isText: false, isStrong: false, isEm: false, isStrongEm: false, isCode: false, isLink: true, href: h });function runAt(s, at, c, n, esc) {let i = at;while (i < s.length) {if (esc && s.charCodeAt(i) === 92) {i += 2;} else if (s.charCodeAt(i) === c) {let k = i;while (k < s.length && s.charCodeAt(k) === c) k++;if (k - i === n) return i;i = k;} else {i++;}}return -1;}function hasCloser(s, at, c, n) {let i = at;while (i < s.length) {const r = runAt(s, i, c, n, true);if (r < 0) return false;const prev = r > 0 ? s.charCodeAt(r - 1) : 32;const next = r + n < s.length ? s.charCodeAt(r + n) : 32;if (r > at && !isSpaceC(prev) && (c !== 95 || !isAlnumC(next))) return true;i = r + n;}return false;}function inlineSpans(src) {const out = [];if (src == null || src === '') return out;const s = String(src);const n = s.length;let buf = '', strong = false, em = false, strongC = 0, emC = 0, i = 0;while (i < n) {const c = s.charCodeAt(i);if (c === 92 && i + 1 < n && isPunctC(s.charCodeAt(i + 1))) {buf += s[i + 1];i += 2;} else if (c === 96) {let k = i;while (k < n && s.charCodeAt(k) === 96) k++;const close = runAt(s, k, 96, k - i, false);if (close < 0) {buf += s.slice(i, k);i = k;} else {if (buf !== '') out.push(textSpan(buf, strong, em));buf = '';let code = s.slice(k, close).split(NL).join(' ');if (code.length > 2 && code.startsWith(' ') && code.endsWith(' ') && code.trim() !== '') code = code.slice(1, code.length - 1);out.push(codeSpan(code));i = close + (k - i);}} else if (c === 91) {const l = linkAt(s, i);if (l == null) {buf += '[';i++;} else if (l.href == null) {buf += s.slice(i, l.end);i = l.end;} else {if (buf !== '') out.push(textSpan(buf, strong, em));buf = '';out.push(linkSpan(l.label, l.href));i = l.end;}} else if (c === 60 && i + 1 < n && isUrlStart(s, i + 1)) {const e = s.indexOf('>', i + 1);const url = e > 0 ? s.slice(i + 1, e) : '';const h = safeHref(url);if (e < 0 || h == null) {buf += '<';i++;} else {if (buf !== '') out.push(textSpan(buf, strong, em));buf = '';out.push(linkSpan(url, h));i = e + 1;}} else if ((c === 104 || c === 72) && (i === 0 || !isAlnumC(s.charCodeAt(i - 1))) && isUrlStart(s, i)) {const e = urlEnd(s, i);const url = s.slice(i, e);const h = safeHref(url);if (h == null) {buf += s[i];i++;} else {if (buf !== '') out.push(textSpan(buf, strong, em));buf = '';out.push(linkSpan(url, h));i = e;}} else if (c === 42 || c === 95) {let k = i;while (k < n && s.charCodeAt(k) === c) k++;const run = k - i;const prev = i > 0 ? s.charCodeAt(i - 1) : 32;const next = k < n ? s.charCodeAt(k) : 32;const wantStrong = run === 2 || run === 3;const wantEm = run === 1 || run === 3;const canOpen = !isSpaceC(next) && (c === 42 || !isAlnumC(prev));const canClose = !isSpaceC(prev) && (c === 42 || !isAlnumC(next));const closes = canClose && run <= 3 && (!wantStrong || (strong && strongC === c)) && (!wantEm || (em && emC === c));const opens = !closes && canOpen && run <= 3 && (!wantStrong || !strong) && (!wantEm || !em) && hasCloser(s, k, c, run);if (closes || opens) {if (buf !== '') out.push(textSpan(buf, strong, em));buf = '';if (wantStrong) { strong = opens; strongC = c; }if (wantEm) { em = opens; emC = c; }} else {buf += s.slice(i, k);}i = k;} else {buf += s[i];i++;}}if (buf !== '') out.push(textSpan(buf, strong, em));return out;}const noCr = (line) => (line.length > 0 && line.charCodeAt(line.length - 1) === 13 ? line.slice(0, line.length - 1) : line);const indentOf = (line) => {let i = 0;while (i < line.length && (line.charCodeAt(i) === 32 || line.charCodeAt(i) === 9)) i++;return i;};function headingOf(line) {if (indentOf(line) > 3) return null;const t = line.trim();let k = 0;while (k < t.length && t.charCodeAt(k) === 35) k++;if (k === 0 || k > 6) return null;if (k < t.length && t.charCodeAt(k) !== 32 && t.charCodeAt(k) !== 9) return null;let body = t.slice(k).trim();let e = body.length;while (e > 0 && body.charCodeAt(e - 1) === 35) e--;if (e === 0) body = '';else if (e < body.length && (body.charCodeAt(e - 1) === 32 || body.charCodeAt(e - 1) === 9)) body = body.slice(0, e).trim();return { level: k, text: body };}function itemOf(line) {const t = line.trim();if (t.length >= 2 && (t.charCodeAt(0) === 45 || t.charCodeAt(0) === 42 || t.charCodeAt(0) === 43) && (t.charCodeAt(1) === 32 || t.charCodeAt(1) === 9)) {return { ordered: false, start: 1, text: t.slice(2).trim() };}if (t.length === 1 && (t === '-' || t === '*' || t === '+')) return { ordered: false, start: 1, text: '' };let k = 0;while (k < t.length && k < 9 && t.charCodeAt(k) >= 48 && t.charCodeAt(k) <= 57) k++;if (k === 0 || k >= t.length) return null;const d = t.charCodeAt(k);if (d !== 46 && d !== 41) return null;if (k + 1 < t.length && t.charCodeAt(k + 1) !== 32 && t.charCodeAt(k + 1) !== 9) return null;return { ordered: true, start: Number(t.slice(0, k)), text: t.slice(k + 1).trim() };}function fenceOf(line) {if (indentOf(line) > 3) return null;const t = line.trim();if (t.length < 3) return null;const c = t.charCodeAt(0);if (c !== 96 && c !== 126) return null;let k = 0;while (k < t.length && t.charCodeAt(k) === c) k++;if (k < 3) return null;if (c === 96 && t.slice(k).includes('`')) return null;return t.slice(0, k);}const blockBase = () => ({ isP: false, isH1: false, isH2: false, isH3: false, isUl: false, isOl: false, isCode: false, spans: [], items: [], start: 1, code: '' });// the text → blocks, exactly as markdown.hl markdownBlocks. `ranges` (optional) receives, per// block, the source lines it came from ({ from, to } line indexes) and the heading level.function markdownBlocks(text, ranges) {const out = [];if (text == null || text === '') return out;const lines = String(text).split(NL);const rg = (from, to, level) => { if (ranges) ranges.push({ from, to, level }); };let para = [], paraFrom = 0;let inList = false, listOrdered = false, listStart = 1, items = [], listFrom = 0, listTo = 0;let blankBefore = false;const flushPara = () => {if (para.length > 0) {out.push(Object.assign(blockBase(), { isP: true, spans: inlineSpans(para.join(NL)) }));rg(paraFrom, paraFrom + para.length, 0);}para = [];};const flushList = () => {if (inList) {out.push(Object.assign(blockBase(), { isUl: !listOrdered, isOl: listOrdered, items: items.map((ls) => ({ spans: inlineSpans(ls.join(NL)) })), start: listStart }));rg(listFrom, listTo, 0);}inList = false;items = [];};let i = 0;while (i < lines.length) {const line = noCr(lines[i]);const fence = fenceOf(line);const h = fence == null ? headingOf(line) : null;const it = fence == null && h == null ? itemOf(line) : null;if (fence != null) {flushPara();flushList();const code = [];let j = i + 1, closed = false;const fc = fence.charCodeAt(0);while (j < lines.length && !closed) {const l = noCr(lines[j]);const f = fenceOf(l);if (f != null && f.charCodeAt(0) === fc && f.length >= fence.length && l.trim() === f) closed = true;else code.push(l);j++;}out.push(Object.assign(blockBase(), { isCode: true, code: code.join(NL) }));rg(i, j, 0);i = j;blankBefore = false;} else if (line.trim() === '') {flushPara();blankBefore = true;i++;} else if (h != null) {flushPara();flushList();out.push(Object.assign(blockBase(), { isH1: h.level === 1, isH2: h.level === 2, isH3: h.level >= 3, spans: inlineSpans(h.text) }));rg(i, i + 1, h.level);blankBefore = false;i++;} else if (it != null) {flushPara();if (inList && listOrdered !== it.ordered) flushList();if (!inList) {inList = true;listOrdered = it.ordered;listStart = it.start;items = [];listFrom = i;}items.push([it.text]);listTo = i + 1;blankBefore = false;i++;} else if (inList && (!blankBefore || indentOf(line) >= 2)) {items[items.length - 1].push(line.trim());listTo = i + 1;blankBefore = false;i++;} else {flushList();if (para.length === 0) paraFrom = i;para.push(line.trim());blankBefore = false;i++;}}flushPara();flushList();return out;}// =============================================================================================// 2. THE EDITOR MODEL — blocks of items of segments// block { t: 'p'|'h'|'ul'|'ol'|'code', level, start, items: [segs], o (index of the original// block it came from, -1 = new) }; p / h / code have exactly one item.// seg { x: text, s: strong, e: em, c: code span, h: href | null, a: auto link (a bare URL) }// =============================================================================================const seg = (x, s = false, e = false, c = false, h = null, a = false) => ({ x, s, e, c, h, a });const same = (p, q) => p.s === q.s && p.e === q.e && p.c === q.c && p.h === q.h && p.a === q.a;function merge(segs) {const out = [];for (const g of segs) {if (!g.x) continue;const last = out[out.length - 1];if (last && same(last, g)) last.x += g.x;else out.push(seg(g.x, !!g.s, !!g.e, !!g.c, g.h || null, !!g.a));}return out;}function toChars(segs) {const out = [];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 });return out;}const fromChars = (chars) => merge(chars);const textOf = (segs) => segs.map((g) => g.x).join('');const lenOf = (segs) => segs.reduce((n, g) => n + g.x.length, 0);const isWs = (ch) => ch === ' ' || ch === '\t' || ch === NL || ch === '\r';const block = (t, items, extra) => Object.assign({ t, level: 1, start: 1, items, o: -1 }, extra || {});const clone = (doc) => doc.map((b) => Object.assign({}, b, { items: b.items.map((it) => it.map((g) => Object.assign({}, g))) }));function spansToSegs(spans) {return merge(spans.map((sp) => {if (sp.isCode) return seg(sp.text, false, false, true);if (sp.isLink) return seg(sp.text, false, false, false, sp.href, sp.text === sp.href);return seg(sp.text, sp.isStrong || sp.isStrongEm, sp.isEm || sp.isStrongEm);}));}function fromParsed(pb, level) {if (pb.isP) return block('p', [spansToSegs(pb.spans)]);if (pb.isH1 || pb.isH2 || pb.isH3) return block('h', [spansToSegs(pb.spans)], { level: level || (pb.isH1 ? 1 : pb.isH2 ? 2 : 3) });if (pb.isUl) return block('ul', pb.items.map((it) => spansToSegs(it.spans)));if (pb.isOl) return block('ol', pb.items.map((it) => spansToSegs(it.spans)), { start: pb.start });return block('code', [pb.code ? [seg(pb.code)] : []]);}// ---- editor normalisation: what the model may hold at all ---------------------------------// (code blocks plain, headings one line, code spans / links carry no emphasis and no line// break, bare URLs are links — as the renderer shows them — and adjacent lists of one kind are// one list, because Markdown cannot keep them apart)function autolink(chars) {// demote auto links that were typed into or touch text (they are re-detected below)let i = 0;while (i < chars.length) {if (chars[i].a && chars[i].h) {let k = i;while (k < chars.length && chars[k].a && chars[k].h === chars[i].h) k++;const text = chars.slice(i, k).map((c) => c.x).join('');const before = i > 0 ? chars[i - 1] : null;const after = k < chars.length ? chars[k] : null;const touches = (c) => c && !c.h && !c.c && !isWs(c.x);if (text !== chars[i].h || touches(before) || touches(after)) {for (let j = i; j < k; j++) { chars[j].h = null; chars[j].a = false; }}i = k;} else i++;}// detect bare URLs in runs of plain text, the way the renderer doesi = 0;while (i < chars.length) {if (chars[i].h || chars[i].c) { i++; continue; }let k = i;while (k < chars.length && !chars[k].h && !chars[k].c) k++;const T = chars.slice(i, k).map((c) => c.x).join('');let j = 0;while (j < T.length) {const c = T.charCodeAt(j);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);if ((c === 104 || c === 72) && !isAlnumC(prevC) && isUrlStart(T, j)) {const e = urlEnd(T, j);const url = T.slice(j, e);const h = safeHref(url);if (h != null) {for (let m = i + j; m < i + e; m++) Object.assign(chars[m], { h: url, a: true, s: false, e: false, c: false });j = e;continue;}}j++;}i = k;}return chars;}function edItem(segs, t) {let chars = toChars(segs);if (t === 'code') return chars.length ? [seg(chars.map((c) => c.x).join(''))] : [];for (const ch of chars) {if (ch.h) { ch.s = false; ch.e = false; ch.c = false; }if (ch.c) { ch.s = false; ch.e = false; ch.h = null; ch.a = false; }if (!ch.h) ch.a = false;if ((ch.c || ch.h) && (ch.x === NL || ch.x === '\r')) ch.x = ' ';if (t === 'h' && (ch.x === NL || ch.x === '\r')) ch.x = ' ';}chars = autolink(chars);return fromChars(chars);}function edDoc(doc) {const out = [];for (const b0 of doc) {const b = Object.assign({}, b0);if (b.t === 'code') {b.items = [edItem([seg(b.items.map(textOf).join(NL))], 'code')];} else if (b.t === 'p' || b.t === 'h') {b.items = [edItem(b.items.length ? [].concat(...b.items.map((it, k) => (k ? [seg(NL)].concat(it) : it))) : [], b.t)];} else {b.items = (b.items.length ? b.items : [[]]).map((it) => edItem(it, b.t));}if (b.t === 'h') b.level = Math.min(6, Math.max(1, b.level | 0 || 1));if (b.t === 'ol') b.start = Number.isFinite(b.start) && b.start >= 0 ? b.start : 1;const prev = out[out.length - 1];if (prev && (b.t === 'ul' || b.t === 'ol') && prev.t === b.t) {prev.items = prev.items.concat(b.items);prev.o = -1;continue;}out.push(b);}if (!out.length) out.push(block('p', [[]]));return out;}// ---- what is stored: empty blocks, empty lines and line-edge spaces cannot be kept ---------function trimLines(chars, dropEmpty) {// spaces/tabs at the item's edges and around line breaks go; empty lines go (dropEmpty) or// split the item (returned as several)const lines = [[]];for (const ch of chars) {if (ch.x === NL) lines.push([]);else if (ch.x !== '\r') lines[lines.length - 1].push(ch);}const trimmed = lines.map((l) => {let a = 0, b = l.length;while (a < b && (l[a].x === ' ' || l[a].x === '\t')) a++;while (b > a && (l[b - 1].x === ' ' || l[b - 1].x === '\t')) b--;return l.slice(a, b);});const groups = [[]];for (const l of trimmed) {if (l.length === 0) { if (!dropEmpty && groups[groups.length - 1].length) groups.push([]); continue; }groups[groups.length - 1].push(l);}return groups.filter((g) => g.length).map((g) => {const out = [];g.forEach((l, k) => { if (k) out.push({ x: NL, s: false, e: false, c: false, h: null, a: false }); out.push(...l); });return out;});}function fixLabels(chars) {for (const ch of chars) if (ch.h) { if (ch.x === '[') ch.x = '('; if (ch.x === ']') ch.x = ')'; }return chars;}function storable(b) {if (b.t === 'code') {const text = textOf(b.items[0] || []);return text === '' ? [] : [block('code', [[seg(text)]])];}if (b.t === 'p') return trimLines(fixLabels(toChars(b.items[0] || [])), false).map((cs) => block('p', [fromChars(cs)]));if (b.t === 'h') {const g = trimLines(fixLabels(toChars(b.items[0] || [])), true);return g.length ? [block('h', [fromChars(g[0])], { level: b.level })] : [];}const items = [];for (const it of b.items) {const g = trimLines(fixLabels(toChars(it)), true);if (g.length) items.push(fromChars(g[0]));}return items.length ? [block(b.t, items, { start: b.start })] : [];}// ---- the semantic key: what the renderer would show (two texts with the same key look the same)function itemKey(segs) {const chars = toChars(segs);for (const ch of chars) if (!ch.c && !ch.h && isWs(ch.x)) { ch.s = false; ch.e = false; }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 || '']);}function blockKey(b) {const kind = b.t === 'h' ? 'h' + Math.min(3, b.level) : b.t;if (b.t === 'code') return JSON.stringify(['code', textOf(b.items[0] || [])]);return JSON.stringify([kind, b.t === 'ol' ? b.start : 0, b.items.map(itemKey)]);}const keysOf = (blocks) => blocks.map(blockKey).join(NL);// the stored form of parsed Markdown (as the editor would hold it), as keysfunction parsedKeys(md) {const ranges = [];const pbs = markdownBlocks(md, ranges);const doc = edDoc(pbs.map((pb, k) => fromParsed(pb, ranges[k].level)));return keysOf([].concat(...doc.map(storable)));}// =============================================================================================// 3. MODEL → MARKDOWN (canonical, verified)// =============================================================================================const VARIANTS = [{ em: '*', st: '**', auto: 'bare' }, { em: '*', st: '**', auto: 'angle' },{ em: '_', st: '**', auto: 'bare' }, { em: '_', st: '**', auto: 'angle' },{ em: '*', st: '__', auto: 'angle' }, { em: '_', st: '__', auto: 'angle' },];function codeMd(x) {let run = 0, longest = 0;for (const ch of x) { if (ch === '`') { run++; longest = Math.max(longest, run); } else run = 0; }const fence = '`'.repeat(longest + 1);const pad = x.startsWith('`') || x.endsWith('`') || (x.startsWith(' ') && x.endsWith(' ') && x.trim() !== '') ? ' ' : '';return fence + pad + x + pad + fence;}// an auto link is written bare where the renderer finds exactly it again (prev not a letter,// the URL does not run on into what follows), else in <…>function linkMd(g, v, prev, after) {if (g.a && g.x === g.h && /^https?:\/\//i.test(g.h)) {const bare = v.auto === 'bare' && !isAlnumC(prev.charCodeAt(0) || 32) && urlEnd(g.x + after, 0) === g.x.length;return bare ? g.x : '<' + g.x + '>';}return '[' + g.x + '](' + g.h.replace(/\)/g, '%29') + ')';}// pieces: strings (fixed) and { ch, risky, esc } (a text character that may need a backslash)function buildPieces(segs, v, ctx) {const out = [];let line = '';const put = (str) => { out.push(str); const k = str.lastIndexOf(NL); line = k >= 0 ? str.slice(k + 1) : line + str; };const putCh = (ch) => {const lead = line.trim() === '';let risky = false;if ('\\`*_[<'.includes(ch)) risky = true;else if (ch === '#') risky = lead || ctx === 'h';else if ('-+~'.includes(ch)) risky = lead;else if (ch === '.' || ch === ')') risky = /^\s*\d+$/.test(line);else if (ch === ':') risky = /https?$/i.test(line);if (ch === NL) { put(NL); return; }out.push({ ch, risky, esc: false });line += ch;};let cur = { s: false, e: false }, pend = '';const markers = (next) => {let m = '';if (cur.e && !next.e) m += v.em;if (cur.s && !next.s) m += v.st;let o = '';if (!cur.s && next.s) o += v.st;if (!cur.e && next.e) o += v.em;return [m, o];};const flushPend = () => { for (const ch of pend) putCh(ch); pend = ''; };const ms = merge(segs);ms.forEach((g, k) => {if (g.h) {flushPend();const nx = ms[k + 1];const after = !nx ? '' : nx.h ? '[' : nx.c ? '`' : (cur.s || cur.e) && !(nx.s === cur.s && nx.e === cur.e) ? '*' : nx.x;put(linkMd(g, v, line.slice(-1), after));return;}if (g.c) { flushPend(); put(codeMd(g.x)); return; }let a = 0, b = g.x.length;while (a < b && isWs(g.x[a])) a++;while (b > a && isWs(g.x[b - 1])) b--;if (a === b) { pend += g.x; return; }const [close, open] = markers(g);if (close) put(close);flushPend();for (const ch of g.x.slice(0, a)) putCh(ch);if (open) put(open);for (const ch of g.x.slice(a, b)) putCh(ch);pend = g.x.slice(b);cur = { s: g.s, e: g.e };});const [close] = markers({ s: false, e: false });if (close) put(close);flushPend();return out;}const piecesStr = (ps) => ps.map((p) => (typeof p === 'string' ? p : p.esc ? '\\' + p.ch : p.ch)).join('');const wrapCtx = (md, ctx, level) => (ctx === 'h' ? '#'.repeat(level) + ' ' + md : ctx === 'li' ? '- ' + md.split(NL).join(NL + ' ') : md);const inlineCache = new Map();function inlineMd(segs, ctx, level) {const ck = ctx + (level || '') + JSON.stringify(segs);if (inlineCache.has(ck)) return inlineCache.get(ck);const target = JSON.stringify(itemKey(segs));const check = (md) => {const ranges = [];const bl = markdownBlocks(wrapCtx(md, ctx, level), ranges);if (bl.length !== 1) return md === '' && segs.length === 0;const b = edDoc([fromParsed(bl[0], ranges[0].level)])[0];if (ctx === 'p' && b.t !== 'p') return false;if (ctx === 'h' && b.t !== 'h') return false;if (ctx === 'li' && (b.t !== 'ul' || b.items.length !== 1)) return false;return JSON.stringify(itemKey(b.items[0])) === target;};let result = null;for (const v of VARIANTS) {const s = piecesStr(buildPieces(segs, v, ctx));if (check(s)) { result = s; break; }}if (result == null) {for (const v of VARIANTS) {const ps = buildPieces(segs, v, ctx);const risky = ps.filter((p) => typeof p !== 'string' && p.risky);risky.forEach((p) => { p.esc = true; });if (!check(piecesStr(ps))) continue;for (const p of risky) { p.esc = false; if (!check(piecesStr(ps))) p.esc = true; }result = piecesStr(ps);break;}}if (result == null) {// cannot be written exactly (should not happen): the fully escaped first variantconst ps = buildPieces(segs, VARIANTS[0], ctx);ps.forEach((p) => { if (typeof p !== 'string' && p.risky) p.esc = true; });result = piecesStr(ps);}if (inlineCache.size > 2000) inlineCache.clear();inlineCache.set(ck, result);return result;}function blockMd(b) {if (b.t === 'p') return inlineMd(b.items[0], 'p');if (b.t === 'h') return '#'.repeat(b.level) + ' ' + inlineMd(b.items[0], 'h', b.level);if (b.t === 'ul') return b.items.map((it) => '- ' + inlineMd(it, 'li').split(NL).join(NL + ' ')).join(NL);if (b.t === 'ol') {return b.items.map((it, k) => {const mark = String(b.start + k) + '. ';return mark + inlineMd(it, 'li').split(NL).join(NL + ' '.repeat(mark.length));}).join(NL);}const text = textOf(b.items[0] || []);let n = 3;for (const l of text.split(NL)) { const m = /^\s*(`{3,})/.exec(l); if (m && m[1].length >= n) n = m[1].length + 1; }const fence = '`'.repeat(n);return fence + NL + text + NL + fence;}// the original text, cut into its blocks (sources, the lines between them, their keys)function prepareOriginal(md) {const text = md == null ? '' : String(md);const ranges = [];const pbs = markdownBlocks(text, ranges);const lines = text.split(NL);const doc = pbs.map((pb, k) => Object.assign(fromParsed(pb, ranges[k].level), { o: k }));const src = ranges.map((r) => lines.slice(r.from, r.to).join(NL));const keys = doc.map((b) => keysOf([].concat(...edDoc([b]).map(storable))));const joiners = ranges.map((r, k) => (k === 0 ? '' : NL + lines.slice(ranges[k - 1].to, r.from).map((l) => l + NL).join('')));const prefix = ranges.length ? lines.slice(0, ranges[0].from).map((l) => l + NL).join('') : '';const suffix = ranges.length ? lines.slice(ranges[ranges.length - 1].to).map((l) => NL + l).join('') : '';return { text, doc, src, keys, joiners, prefix, suffix, n: ranges.length };}function serialize(doc, orig) {const parts = [];const used = new Set();for (const b of doc) {const st = storable(b);const key = keysOf(st);const from = orig && b.o >= 0 && b.o < orig.n && !used.has(b.o) ? b.o : -1;if (from >= 0 && orig.keys[from] === key && key !== '') {used.add(from);parts.push({ md: orig.src[from], first: from, last: from, key });continue;}if (from >= 0) used.add(from);// a changed block is written anew, but keeps the blank lines around itst.forEach((sb, k) => parts.push({ md: blockMd(sb), first: k === 0 ? from : -1, last: k === st.length - 1 ? from : -1, key: blockKey(sb) }));}const expected = parts.map((p) => p.key).join(NL);let out = '';parts.forEach((p, k) => {if (k === 0) out += orig && p.first === 0 ? orig.prefix : '';else out += orig && p.first > 0 && parts[k - 1].last === p.first - 1 ? orig.joiners[p.first] : NL + NL;out += p.md;});if (orig && parts.length && parts[parts.length - 1].last === orig.n - 1 && orig.n > 0) out += orig.suffix;if (parsedKeys(out) === expected) return out;// the neighbours changed how a kept block reads: everything canonicalconst canon = [].concat(...doc.map(storable)).map(blockMd).join(NL + NL);return canon;}// =============================================================================================// 4. MODEL OPERATIONS on linear positions (every item counts its length + 1)// =============================================================================================function fromLin(doc, n) {let rest = Math.max(0, n);for (let b = 0; b < doc.length; b++) {for (let i = 0; i < doc[b].items.length; i++) {const L = lenOf(doc[b].items[i]);if (rest <= L) return { b, i, o: rest };rest -= L + 1;}}const b = doc.length - 1, i = doc[b].items.length - 1;return { b, i, o: lenOf(doc[b].items[i]) };}function toLin(doc, p) {let n = 0;for (let b = 0; b < p.b; b++) for (const it of doc[b].items) n += lenOf(it) + 1;for (let i = 0; i < p.i; i++) n += lenOf(doc[p.b].items[i]) + 1;return n + p.o;}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]) });function delRange(doc, a, b) {if (b <= a) return doc;const p = fromLin(doc, a), q = fromLin(doc, b);const bp = doc[p.b], bq = doc[q.b];const merged = fromChars(toChars(bp.items[p.i]).slice(0, p.o).concat(toChars(bq.items[q.i]).slice(q.o)));if (p.b === q.b) {bp.items.splice(p.i, q.i - p.i + 1, merged);return doc;}const rest = bq.items.slice(q.i + 1);bp.items = bp.items.slice(0, p.i).concat([merged]);const repl = rest.length ? [block(bq.t, rest, { level: bq.level, start: bq.start + q.i + 1 })] : [];doc.splice(p.b + 1, q.b - p.b, ...repl);return doc;}function insChars(doc, a, chars) {const p = fromLin(doc, a);const it = toChars(doc[p.b].items[p.i]);it.splice(p.o, 0, ...chars);doc[p.b].items[p.i] = fromChars(it);return a + chars.length;}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 || {}));function opEnter(doc, a) {const p = fromLin(doc, a);const blk = doc[p.b];const chars = toChars(blk.items[p.i]);const head = fromChars(chars.slice(0, p.o)), tail = fromChars(chars.slice(p.o));if (blk.t === 'code') {const text = chars.map((c) => c.x).join('');if (p.o === text.length && text.endsWith(NL)) {blk.items[0] = fromChars(chars.slice(0, -1));doc.splice(p.b + 1, 0, block('p', [[]]));return toLin(doc, { b: p.b + 1, i: 0, o: 0 });}return insChars(doc, a, plainChars(NL));}if (blk.t === 'ul' || blk.t === 'ol') {if (chars.every((c) => isWs(c.x))) {const before = blk.items.slice(0, p.i), after = blk.items.slice(p.i + 1);const repl = [];if (before.length) repl.push(block(blk.t, before, { start: blk.start, o: blk.o }));repl.push(block('p', [[]]));if (after.length) repl.push(block(blk.t, after, { start: blk.start + p.i + 1 }));doc.splice(p.b, 1, ...repl);return toLin(doc, { b: p.b + (before.length ? 1 : 0), i: 0, o: 0 });}blk.items.splice(p.i, 1, head, tail);return toLin(doc, { b: p.b, i: p.i + 1, o: 0 });}if (blk.t === 'h' && p.o === 0 && chars.length) {doc.splice(p.b, 0, block('p', [[]]));return toLin(doc, { b: p.b + 1, i: 0, o: 0 });}blk.items[0] = head;doc.splice(p.b + 1, 0, block('p', [tail]));return toLin(doc, { b: p.b + 1, i: 0, o: 0 });}function opBackspaceAtStart(doc, a) {const p = fromLin(doc, a);const blk = doc[p.b];if (blk.t === 'h' || blk.t === 'code') { blk.t = 'p'; blk.o = -1; return a; }if (blk.t === 'ul' || blk.t === 'ol') {if (p.i === 0) {const rest = blk.items.slice(1);const repl = [block('p', [blk.items[0]])];if (rest.length) repl.push(block(blk.t, rest, { start: blk.start + 1 }));doc.splice(p.b, 1, ...repl);return a;}blk.items.splice(p.i - 1, 2, blk.items[p.i - 1].concat(blk.items[p.i]));return a - 1;}if (p.b === 0) return a;const prev = doc[p.b - 1];if ((prev.t === 'p' || prev.t === 'h') && lenOf(prev.items[0]) === 0) {doc.splice(p.b - 1, 1);return a - 1;}const last = prev.items.length - 1;prev.items[last] = prev.items[last].concat(blk.items[0]);doc.splice(p.b, 1);return a - 1;}function opDeleteAtEnd(doc, a) {const p = fromLin(doc, a);const blk = doc[p.b];if ((blk.t === 'ul' || blk.t === 'ol') && p.i < blk.items.length - 1) {blk.items.splice(p.i, 2, blk.items[p.i].concat(blk.items[p.i + 1]));return a;}if (p.b >= doc.length - 1) return a;if (blk.t === 'p' && lenOf(blk.items[0]) === 0) { doc.splice(p.b, 1); return a; }const next = doc[p.b + 1];blk.items[p.i] = blk.items[p.i].concat(next.items[0]);next.items.shift();if (!next.items.length) doc.splice(p.b + 1, 1);return a;}function opBlockType(doc, a, b, type, level) {const P = fromLin(doc, a), Q = fromLin(doc, b);const blocks = doc.slice(P.b, Q.b + 1);const is = (blk) => (type === 'h' ? blk.t === 'h' && blk.level === level : blk.t === type);const target = blocks.every(is) ? 'p' : type;const lines = (it) => {const out = [[]];for (const ch of toChars(it)) { if (ch.x === NL) out.push([]); else out[out.length - 1].push(ch); }return out.map(fromChars);};let repl = [];if (target === 'code') {const text = [].concat(...blocks.map((blk) => blk.items)).map(textOf).join(NL);repl = [block('code', [text ? [seg(text)] : []])];} else if (target === 'ul' || target === 'ol') {const items = [];for (const blk of blocks) {if (blk.t === 'p' || blk.t === 'code') items.push(...lines(blk.items[0]));else items.push(...blk.items);}const first = blocks.find((blk) => blk.t === 'ol');repl = [block(target, items, { start: target === 'ol' && first ? first.start : 1 })];} else {for (const blk of blocks) {for (const it of blk.items) repl.push(block(target, [it], { level: level || 1 }));}}doc.splice(P.b, blocks.length, ...repl);return doc;}// every item overlapping [a, b) (code blocks skipped): fn(chars, from, to) edits chars in placefunction eachInRange(doc, a, b, fn) {let n = 0;for (const blk of doc) {for (let i = 0; i < blk.items.length; i++) {const L = lenOf(blk.items[i]);const from = Math.max(a, n), to = Math.min(b, n + L);if (from < to && blk.t !== 'code') {const chars = toChars(blk.items[i]);fn(chars, from - n, to - n);blk.items[i] = fromChars(chars);}n += L + 1;}}}function charsInRange(doc, a, b) {const out = [];eachInRange(clone(doc), a, b, (chars, f, t) => { out.push(...chars.slice(f, t)); });return out;}function opToggle(doc, a, b, attr) {const all = charsInRange(doc, a, b).filter((ch) => !isWs(ch.x) && (attr === 'c' ? !ch.h : !ch.c && !ch.h));const on = !(all.length && all.every((ch) => ch[attr]));eachInRange(doc, a, b, (chars, f, t) => {for (let k = f; k < t; k++) {const ch = chars[k];if (ch.x === NL) continue;if (attr === 'c') {if (ch.h) continue;ch.c = on;if (on) { ch.s = false; ch.e = false; }} else if (!ch.c && !ch.h) ch[attr] = on;}});return doc;}function opLink(doc, a, b, href) {eachInRange(doc, a, b, (chars, f, t) => {for (let k = f; k < t; k++) {const ch = chars[k];if (ch.x === NL) continue;if (href) Object.assign(ch, { h: href, a: false, s: false, e: false, c: false });else Object.assign(ch, { h: null, a: false });}});return doc;}function linkExtent(doc, a) {const p = fromLin(doc, a);const chars = toChars(doc[p.b].items[p.i]);let k = p.o < chars.length && chars[p.o].h ? p.o : p.o > 0 && chars[p.o - 1].h ? p.o - 1 : -1;if (k < 0) return null;const h = chars[k].h;let f = k, t = k;while (f > 0 && chars[f - 1].h === h) f--;while (t < chars.length && chars[t].h === h) t++;const base = a - p.o;return { a: base + f, b: base + t, href: h, auto: chars[k].a };}// =============================================================================================// 5. DOM ⇄ MODEL// =============================================================================================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']);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']);// reads DOM content into items. `opts.html` = foreign HTML (paste): whitespace collapses and// styles (font-weight / font-style) count; else it is our own editor DOM. `marks`: DOM points// whose model positions are wanted ({ node, off } → .lin).function readDom(root, opts) {const html = !!(opts && opts.html);const marks = (opts && opts.marks) || [];const doc = [];let lin = 0; // linear position at the start of the open itemlet cur = null; // the open item: { blk, chars, marks: [] }let waiting = []; // marks met between items: they belong to the start of the next oneconst open = (blk) => {close();cur = { blk, chars: [], marks: [] };for (const m of waiting) { m.rel = 0; cur.marks.push(m); }waiting = [];};const close = () => {if (!cur) return;const chars = cur.chars;if (!html && chars.length && chars[chars.length - 1].x === NL) chars.pop(); // the caret's filler <br>if (html) {// collapsed HTML whitespace: none at the item's edges or around line breaksconst lines = [[]];for (const ch of chars) { if (ch.x === NL) lines.push([]); else lines[lines.length - 1].push(ch); }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); });chars.length = 0;t.forEach((l, k) => { if (k) chars.push({ x: NL, s: false, e: false, c: false, h: null, a: false }); chars.push(...l); });}for (const m of cur.marks) m.lin = lin + Math.min(m.rel, chars.length);const blk = cur.blk;if (!blk.pushed) { blk.pushed = true; doc.push(blk); }blk.items.push(fromChars(chars));lin += chars.length + 1;cur = null;};const ensure = () => { if (!cur) open(block('p', [])); };const markAt = (node, off) => {for (const m of marks) {if (m.node !== node || m.off !== off || m.lin != null) continue;m.lin = -2;if (cur) { cur.marks.push(m); m.rel = cur.chars.length; } else waiting.push(m);}};const pushText = (s, at) => {ensure();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 });};const attrsOf = (el, at) => {const t = el.tagName;const n = Object.assign({}, at);if (t === 'STRONG' || t === 'B') n.s = true;if (t === 'EM' || t === 'I' || t === 'CITE' || t === 'DFN' || t === 'VAR') n.e = true;if (t === 'CODE' || t === 'KBD' || t === 'SAMP' || t === 'TT') n.c = true;if (t === 'A') {const h = safeHref(el.getAttribute('href'));if (h) { n.h = h; n.a = !html && el.hasAttribute('data-auto'); }}if (html && el.style) {const fw = el.style.fontWeight;if (fw === 'bold' || fw === 'bolder' || Number(fw) >= 600) n.s = true;if (fw === 'normal' || fw === 'lighter' || (Number(fw) > 0 && Number(fw) < 600)) n.s = false;if (el.style.fontStyle === 'italic' || el.style.fontStyle === 'oblique') n.e = true;if (el.style.fontStyle === 'normal') n.e = false;}return n;};const textOfPre = (el) => {let s = '';const walk = (n) => {for (const c of n.childNodes) {if (c.nodeType === 3) s += c.data;else if (c.nodeType === 1 && c.tagName === 'BR') s += NL;else if (c.nodeType === 1 && !SKIP_TAGS.has(c.tagName)) walk(c);}};walk(el);return s;};const inline = (n, at, pre) => {if (n.nodeType === 3) {let s = n.data;if (html && !pre) s = s.replace(/[\t\n\r\f ]+/g, ' ');else s = s.replace(/\r\n?/g, NL);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; } }if (s) pushText(s, at);return;}if (n.nodeType !== 1 || SKIP_TAGS.has(n.tagName)) {if (n.nodeType === 1 && n.tagName === 'IMG' && html && n.getAttribute('alt')) pushText(n.getAttribute('alt'), at);return;}if (n.tagName === 'BR') { ensure(); cur.chars.push({ x: NL, s: false, e: false, c: false, h: null, a: false }); return; }if (BLOCK_TAGS.has(n.tagName)) { blockEl(n, at); return; }if ((n.tagName === 'TD' || n.tagName === 'TH') && cur && cur.chars.length) pushText(' ', {});const na = attrsOf(n, at);children(n, na, pre);};const children = (n, at, pre) => {const kids = Array.from(n.childNodes);kids.forEach((c, k) => { markAt(n, k); inline(c, at, pre); });markAt(n, kids.length);};const listEl = (el, at) => {const t = el.tagName === 'OL' ? 'ol' : 'ul';const start = t === 'ol' && el.hasAttribute('start') ? Number(el.getAttribute('start')) || 1 : 1;const blk = block(t, [], { start });if (!html && el.__mdO != null) blk.o = el.__mdO;close();const kids = Array.from(el.childNodes);kids.forEach((c, k) => {markAt(el, k);if (c.nodeType === 1 && c.tagName === 'LI') {open(blk);const inner = Array.from(c.childNodes);inner.forEach((d, j) => {markAt(c, j);if (d.nodeType === 1 && (d.tagName === 'UL' || d.tagName === 'OL')) {// a nested list: its items become items of this one (one level only)for (const li of d.children) { open(blk); children(li, at, false); }} else if (d.nodeType === 1 && BLOCK_TAGS.has(d.tagName) && d.tagName !== 'LI') {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 });children(d, attrsOf(d, at), false);} else inline(d, at, false);});markAt(c, inner.length);close();} else if (c.nodeType === 3 && !c.data.trim()) {// whitespace between items} else {open(blk);inline(c, at, false);close();}});markAt(el, kids.length);if (!blk.items.length && !html) { open(blk); close(); }close();};const blockEl = (el, at) => {const t = el.tagName;if (t === 'HR') { close(); return; }if (t === 'UL' || t === 'OL') { listEl(el, at); return; }if (t === 'LI') { const fake = { tagName: 'UL', childNodes: [el], hasAttribute: () => false }; listEl(fake, at); return; }if (t === 'PRE') {const blk = block('code', []);if (!html && el.__mdO != null) blk.o = el.__mdO;open(blk);if (html) pushText(textOfPre(el).replace(/\n$/, ''), {});else children(el, {}, true);close();return;}if (/^H[1-6]$/.test(t)) {const blk = block('h', [], { level: Number(t[1]) });if (!html && el.__mdO != null) blk.o = el.__mdO;open(blk);children(el, html ? attrsOf(el, at) : at, false);close();return;}// P / DIV / … : a paragraph, unless it holds blocks itselfconst hasBlocks = Array.from(el.children).some((c) => BLOCK_TAGS.has(c.tagName));if (hasBlocks) {close();const na = html ? attrsOf(el, at) : at;const kids = Array.from(el.childNodes);kids.forEach((c, k) => {markAt(el, k);if (c.nodeType === 1 && BLOCK_TAGS.has(c.tagName)) { close(); blockEl(c, na); close(); }else inline(c, na, false);});markAt(el, kids.length);close();return;}const blk = block('p', []);if (!html && el.__mdO != null) blk.o = el.__mdO;open(blk);children(el, html ? attrsOf(el, at) : at, false);close();};const kids = Array.from(root.childNodes);kids.forEach((c, k) => {markAt(root, k);if (c.nodeType === 1 && BLOCK_TAGS.has(c.tagName)) { close(); blockEl(c, {}); close(); }else if (c.nodeType === 3 && !html && c.data === '') { /* nothing */ }else if (c.nodeType === 3 && html && !cur && !c.data.trim()) { /* whitespace between blocks */ }else inline(c, {}, false);});markAt(root, kids.length);close();for (const m of marks) if (m.lin == null || m.lin < 0) m.lin = lin > 0 ? lin - 1 : 0; // after the last itemconst out = doc.map((b) => { delete b.pushed; return b; });return html ? out.filter((b) => b.t === 'code' || b.items.some((it) => lenOf(it) > 0)) : out;}function renderInline(parent, segs) {let text = '';for (const g of merge(segs)) {let host = parent;const wrap = (tag) => { const el = document.createElement(tag); host.appendChild(el); host = el; return el; };if (g.h) {const a = wrap('a');a.setAttribute('href', g.h);a.setAttribute('title', g.h);if (g.a) a.setAttribute('data-auto', '');} else if (g.c) wrap('code');else {if (g.s) wrap('strong');if (g.e) wrap('em');}const parts = g.x.split(NL);parts.forEach((p, k) => {if (k) host.appendChild(document.createElement('br'));if (p) host.appendChild(document.createTextNode(p));});text += g.x;}if (text === '' || text.endsWith(NL)) parent.appendChild(document.createElement('br'));}function renderDoc(doc) {const frag = document.createDocumentFragment();for (const b of doc) {let el;if (b.t === 'p') el = document.createElement('p');else if (b.t === 'h') el = document.createElement('h' + Math.min(6, b.level));else if (b.t === 'code') el = document.createElement('pre');else el = document.createElement(b.t);if (b.t === 'ol' && b.start !== 1) el.setAttribute('start', String(b.start));if (b.t === 'ul' || b.t === 'ol') {for (const it of b.items) { const li = document.createElement('li'); renderInline(li, it); el.appendChild(li); }} else renderInline(el, b.items[0] || []);el.__mdO = b.o;frag.appendChild(el);}return frag;}// =============================================================================================// 6. THE ELEMENT// =============================================================================================const ICON = {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>',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>',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>',code: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 7l-5 5 5 5M16 7l5 5-5 5"/></svg>',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>',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>',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>',};const MAC = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || '');const MOD = MAC ? 'Cmd' : 'Ctrl';const TOOLS = [{ id: 'bold', label: '<b>B</b>', title: `Bold (${MOD}+B)` },{ id: 'italic', label: '<i>I</i>', title: `Italic (${MOD}+I)` },{ id: 'code', label: ICON.code, title: `Code (${MOD}+E)` },{ id: 'link', label: ICON.link, title: `Link (${MOD}+K)` },{ id: 'heading', label: 'H', title: `Heading: paragraph → H1 → H2 → H3 (${MOD}+Alt+1/2/3, ${MOD}+Alt+0)` },{ id: 'ul', label: ICON.ul, title: `Bulleted list (${MOD}+Shift+8)` },{ id: 'ol', label: ICON.ol, title: `Numbered list (${MOD}+Shift+7)` },{ id: 'codeblock', label: ICON.block, title: `Code block (${MOD}+Alt+C)` },{ id: 'undo', label: ICON.undo, title: `Undo (${MOD}+Z)` },{ id: 'redo', label: ICON.redo, title: `Redo (${MOD}+Shift+Z)` },];const CSS = `:host {--_bg: var(--md-background, Field);--_text: var(--md-text, FieldText);--_strong: var(--md-text-strong, var(--_text));--_muted: var(--md-text-muted, GrayText);--_border: var(--md-border, GrayText);--_accent: var(--md-accent, Highlight);--_accent-text: var(--md-accent-text, HighlightText);--_link: var(--md-link, var(--_accent));--_code-bg: var(--md-code-background, rgba(127, 127, 127, .18));--_toolbar-bg: var(--md-toolbar-background, transparent);--_danger: var(--md-danger, #f44747);--_radius: var(--md-radius, .4rem);--_mono: var(--md-mono-font, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);display: block;position: relative;min-width: 0;max-width: 100%;font: var(--md-font, inherit);color: var(--_text);text-align: start;}:host([hidden]) { display: none; }* { box-sizing: border-box; }.box { display: flex; flex-direction: column; min-width: 0; background: var(--_bg); border: 1px solid var(--_border); border-radius: var(--_radius); }:host(:focus-within) .box { outline: 2px solid var(--_accent); outline-offset: 1px; }:host([invalid]) .box { border-color: var(--_danger); outline-color: var(--_danger); }[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; }:host([toolbar="none"]) [part~="toolbar"] { display: none; }[part~="toolbar"][hidden] { display: none; }[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; }[part~="button"]:hover { border-color: var(--_border); }[part~="button"]:focus-visible { outline: 2px solid var(--_accent); outline-offset: -1px; }[part~="button"][aria-pressed="true"] { color: var(--_accent-text); background: var(--_accent); }[part~="button"]:disabled { opacity: .45; cursor: default; }[part~="button"] svg { width: 1.15rem; height: 1.15rem; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }[part~="button"] svg circle { fill: currentColor; stroke: none; }[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; }[part~="editor"][hidden] { display: none; }[part~="editor"] > :first-child { margin-top: 0; }[part~="editor"] > :last-child { margin-bottom: 0; }[part~="editor"] p { margin: 0 0 .5rem; }[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); }[part~="editor"] h1 { font-size: var(--md-h1-size, 1.3em); }[part~="editor"] h2 { font-size: var(--md-h2-size, 1.15em); }[part~="editor"] h3, [part~="editor"] h4, [part~="editor"] h5, [part~="editor"] h6 { font-size: var(--md-h3-size, 1.02em); }[part~="editor"] ul, [part~="editor"] ol { margin: 0 0 .5rem; padding-left: 1.4rem; }[part~="editor"] ul { list-style-type: disc; }[part~="editor"] ol { list-style-type: decimal; }[part~="editor"] li + li { margin-top: .15rem; }[part~="editor"] code { font: .88em/1.4 var(--_mono); padding: .05em .3em; background: var(--_code-bg); border-radius: .25rem; }[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); }[part~="editor"] a { color: var(--_link); text-decoration: underline; text-underline-offset: .15em; }[part~="editor"] strong { font-weight: 700; }[part~="editor"] { position: relative; }[part~="editor"].empty::before { content: attr(data-placeholder); position: absolute; left: .7rem; top: .5rem; right: .7rem; color: var(--_muted); pointer-events: none; }[part~="link-form"] { display: flex; flex-wrap: wrap; align-items: center; gap: .35rem; padding: .4rem; border-top: 1px solid var(--_border); }[part~="link-form"][hidden] { display: none; }[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); }[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; }[part~="link-form"] button.primary { color: var(--_accent-text); background: var(--_accent); border-color: var(--_accent); }.linkmsg { flex-basis: 100%; margin: 0; font-size: .85rem; color: var(--_danger); }.linkmsg:empty { display: none; }[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; }[part~="source"][hidden] { display: none; }[part~="footer"] { display: flex; justify-content: flex-end; padding: .25rem .4rem; border-top: 1px solid var(--_border); }:host([toolbar="none"]) [part~="footer"] { display: none; }[part~="footer"] [part~="button"] { min-width: 0; height: auto; padding: .3rem .6rem; font: 600 .8rem/1 system-ui, sans-serif; color: var(--_muted); }[part~="footer"] [part~="button"]:hover { color: var(--_text); }[part~="footer"] [part~="button"][aria-pressed="true"] { color: var(--_accent-text); background: var(--_accent); }.keep { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; opacity: 0; pointer-events: none; }`;const TEXTAREA_VALUE = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value');const shadowSelectionOk = typeof ShadowRoot !== 'undefined' && 'getSelection' in ShadowRoot.prototype;class MdEditor extends HTMLElement {static get formAssociated() { return true; }static get observedAttributes() { return ['placeholder', 'disabled', 'readonly', 'value', 'label']; }constructor() {super();this._internals = this.attachInternals ? this.attachInternals() : null;const root = this.attachShadow({ mode: 'open', delegatesFocus: false });const style = document.createElement('style');style.textContent = CSS;const box = document.createElement('div');box.className = 'box';box.setAttribute('part', 'box');// the toolbar (fixed markup of our own — no user text in it)const bar = document.createElement('div');bar.setAttribute('part', 'toolbar');bar.setAttribute('role', 'toolbar');bar.setAttribute('aria-label', 'Formatting');this._buttons = {};TOOLS.forEach((t, k) => {const b = document.createElement('button');b.type = 'button';b.setAttribute('part', 'button ' + t.id);b.dataset.tool = t.id;b.title = t.title;b.setAttribute('aria-label', t.title);b.tabIndex = k === 0 ? 0 : -1;if (!['link', 'undo', 'redo'].includes(t.id)) b.setAttribute('aria-pressed', 'false');b.innerHTML = t.label;bar.appendChild(b);this._buttons[t.id] = b;});const ed = document.createElement('div');ed.setAttribute('part', 'editor');ed.setAttribute('role', 'textbox');ed.setAttribute('aria-multiline', 'true');ed.contentEditable = 'true';ed.spellcheck = true;// the link formconst lf = document.createElement('form');lf.setAttribute('part', 'link-form');lf.hidden = true;lf.noValidate = true;const li = document.createElement('input');li.type = 'text';li.setAttribute('inputmode', 'url');li.autocomplete = 'off';li.placeholder = 'https://…';li.setAttribute('aria-label', 'Link address');const mkBtn = (text, cls, type) => { const b = document.createElement('button'); b.type = type || 'button'; b.textContent = text; if (cls) b.className = cls; return b; };const lOk = mkBtn('Apply', 'primary', 'submit');const lRemove = mkBtn('Remove link');const lCancel = mkBtn('Cancel');const lMsg = document.createElement('p');lMsg.className = 'linkmsg';lMsg.setAttribute('role', 'alert');lf.append(li, lOk, lRemove, lCancel, lMsg);// the plain-text source view (raw Markdown, for copy/paste and direct editing)const src = document.createElement('textarea');src.setAttribute('part', 'source');src.hidden = true;src.spellcheck = false;src.setAttribute('aria-label', 'Markdown source');const footer = document.createElement('div');footer.setAttribute('part', 'footer');const srcBtn = document.createElement('button');srcBtn.type = 'button';srcBtn.setAttribute('part', 'button source-toggle');srcBtn.setAttribute('aria-pressed', 'false');srcBtn.textContent = 'Markdown source';footer.appendChild(srcBtn);// the enhanced textarea stays in the form, out of sight (a label click reaches it → the editor)const keep = document.createElement('div');keep.className = 'keep';keep.appendChild(document.createElement('slot'));box.append(bar, ed, src, lf, footer);root.append(style, box, keep);this._ed = ed;this._bar = bar;this._src = src;this._srcBtn = srcBtn;this._srcMode = false;this._lf = lf; this._li = li; this._lRemove = lRemove; this._lMsg = lMsg;this._slot = keep.firstChild;this._ta = null;this._doc = [block('p', [[]])];this._orig = prepareOriginal('');this._value = '';this._undo = [];this._redo = [];this._lastSnap = null;this._lastTypeAt = 0;this._pending = null;this._saved = null;this._focusValue = null;this._composing = false;this._loaded = false;ed.addEventListener('beforeinput', (e) => this._onBeforeInput(e));// the editor's own input events stay inside: the page hears ONE input per change, from the// textarea (enhance mode) or from <md-editor> itself, after the value is up to dateed.addEventListener('input', (e) => { e.stopPropagation(); if (!e.isComposing && !this._composing) this._sync(e.inputType, e.data); });ed.addEventListener('beforeinput', (e) => e.stopPropagation());ed.addEventListener('compositionstart', () => { this._composing = true; });ed.addEventListener('compositionend', (e) => {this._composing = false;const d = e.data || '';setTimeout(() => this._sync(d ? 'insertText' : 'insertCompositionText', d ? d[d.length - 1] : null), 0);});ed.addEventListener('keydown', (e) => this._onKey(e));ed.addEventListener('paste', (e) => this._onPaste(e));ed.addEventListener('copy', (e) => this._onCopy(e, false));ed.addEventListener('cut', (e) => this._onCopy(e, true));ed.addEventListener('drop', (e) => this._onDrop(e));ed.addEventListener('dragstart', (e) => e.preventDefault());ed.addEventListener('focus', () => { this._focusValue = this.value; });ed.addEventListener('blur', () => {if (this._focusValue != null && this._focusValue !== this.value) this._fire('change');this._focusValue = null;});// a click in the text must not reach a surrounding <label> (it would focus the hidden// textarea and move the caret); Ctrl/Cmd+click opens a linked.addEventListener('click', (e) => {e.preventDefault();const a = e.target && e.target.closest && e.target.closest('a');if (a && (e.ctrlKey || e.metaKey)) { const h = safeHref(a.getAttribute('href')); if (h) window.open(h, '_blank', 'noopener'); }});bar.addEventListener('mousedown', (e) => { if (e.target.closest('button')) e.preventDefault(); });bar.addEventListener('click', (e) => { const b = e.target.closest('button'); if (b) this._tool(b.dataset.tool); });bar.addEventListener('keydown', (e) => this._barKey(e));lf.addEventListener('submit', (e) => { e.preventDefault(); this._linkApply(); });lRemove.addEventListener('click', () => this._linkApply(true));lCancel.addEventListener('click', () => this._linkClose());lf.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); this._linkClose(); } });srcBtn.addEventListener('click', () => this._toggleSource());src.addEventListener('input', () => {this._value = src.value;this._dirty = true;this.removeAttribute('invalid');this._writeOut(true);});src.addEventListener('focus', () => { this._focusValue = src.value; });src.addEventListener('blur', () => {if (this._focusValue != null && this._focusValue !== src.value) this._fire('change');this._focusValue = null;});src.addEventListener('keydown', (e) => {const mod = MAC ? e.metaKey : e.ctrlKey;if (mod && e.key === 'Enter') {e.preventDefault();const f = this.form || this.closest('form');if (f) { if (f.requestSubmit) f.requestSubmit(); else f.submit(); }}});this._slot.addEventListener('slotchange', () => this._adopt());this._onSelChange = () => this._selChanged();// <md-editor> is form-associated, so a surrounding <label> activates IT: the label's click// arrives on the host itself (not from inside) → the caret goes into the editorthis.addEventListener('click', (e) => { if (e.composedPath()[0] === this) this.focus(); });}// ---- lifecycle --------------------------------------------------------------------------connectedCallback() {document.addEventListener('selectionchange', this._onSelChange);this._adopt();if (!this._loaded && !this._ta) {this._loaded = true;this._load(this.getAttribute('value') || '', false);}this._applyAttrs();}disconnectedCallback() { document.removeEventListener('selectionchange', this._onSelChange); }attributeChangedCallback(name, old, v) {if (name === 'value' && !this._ta && this._loaded && old !== v && !this._dirty) this._load(v || '', false);this._applyAttrs();}formResetCallback() {if (!this._ta) this._load(this.getAttribute('value') || '', true);}formDisabledCallback(disabled) { this._formDisabled = disabled; this._applyAttrs(); }// the textarea child (enhance mode)_adopt() {const ta = Array.from(this.children).find((c) => c.tagName === 'TEXTAREA') || null;if (ta === this._ta) return;this._ta = ta;if (!ta) return;const self = this;if (!ta.__mdEditor) {Object.defineProperty(ta, 'value', {configurable: true,get() { return TEXTAREA_VALUE.get.call(this); },set(v) { TEXTAREA_VALUE.set.call(this, v); if (this.__mdEditor && !this.__mdEditor._writing) this.__mdEditor._external(TEXTAREA_VALUE.get.call(this)); },});ta.addEventListener('invalid', (e) => { e.preventDefault(); const m = ta.__mdEditor; if (m) { m.setAttribute('invalid', ''); m.focus(); } });ta.addEventListener('focus', () => { const m = ta.__mdEditor; if (m) m.focus(); });// a script that writes the value and announces it with an input eventta.addEventListener('input', () => { const m = ta.__mdEditor; if (m && !m._writing && TEXTAREA_VALUE.get.call(ta) !== m._value) m._external(TEXTAREA_VALUE.get.call(ta)); });}ta.__mdEditor = self;ta.tabIndex = -1;this._loaded = true;this._load(TEXTAREA_VALUE.get.call(ta), false);this._applyAttrs();}_applyAttrs() {const ta = this._ta;const off = this.hasAttribute('disabled') || this.hasAttribute('readonly') || !!this._formDisabled || (ta && (ta.disabled || ta.readOnly));this._ed.contentEditable = off ? 'false' : 'true';this._ed.setAttribute('aria-disabled', off ? 'true' : 'false');for (const b of Object.values(this._buttons)) b.disabled = !!off;this._histButtons();const hardOff = this.hasAttribute('disabled') || !!this._formDisabled || (ta && ta.disabled);this._srcBtn.disabled = !!hardOff;this._src.disabled = !!hardOff;this._src.readOnly = !!off;const ph = this.getAttribute('placeholder') || (ta && ta.getAttribute('placeholder')) || '';this._ed.dataset.placeholder = ph;this._src.placeholder = ph;let label = this.getAttribute('label') || (ta && ta.getAttribute('aria-label')) || '';const labels = (this._internals && this._internals.labels && this._internals.labels.length ? this._internals.labels : ta && ta.labels) || [];if (!label && labels[0]) label = Array.from(labels[0].childNodes).filter((n) => n.nodeType === 3).map((n) => n.data).join(' ').trim();if (label) this._ed.setAttribute('aria-label', label);if (ta && ta.required) this._ed.setAttribute('aria-required', 'true');const rows = ta ? Number(ta.getAttribute('rows')) : Number(this.getAttribute('rows'));if (rows > 0) { this._ed.style.minHeight = `calc(${rows} * 1.5em + 1rem)`; this._src.style.minHeight = this._ed.style.minHeight; }}// ---- value ------------------------------------------------------------------------------get value() { return this._value; }set value(v) { this._load(v == null ? '' : String(v), true); this._writeOut(false); }focus(opts) {this._ed.focus(opts);if (!this._rangeLin()) {const s = this._saved;const n = docLen(this._doc);if (s) this._setSel(Math.min(s.a, n), Math.min(s.b, n)); else this._setSel(n, n);}}get form() { return this._ta ? this._ta.form : this._internals ? this._internals.form : null; }get name() { return this._ta ? this._ta.name : this.getAttribute('name'); }_load(md, keepUndo) {const text = md == null ? '' : String(md);if (keepUndo && this._lastSnap) this._pushUndo(this._lastSnap);this._orig = prepareOriginal(text);this._doc = edDoc(clone(this._orig.doc));this._value = text;this._dirty = false;this._pending = null;this._render(true);if (this._src) this._src.value = text;this._lastSnap = this._snap();if (!keepUndo) { this._undo = []; this._redo = []; }if (this._internals && !this._ta) this._internals.setFormValue(text);}_external(v) {if (v === this._value) return;const focused = this._hasFocus();this._load(v, true);if (focused) this._setSel(docLen(this._doc), docLen(this._doc));}_commit() {const md = serialize(this._doc, this._orig);this._dirty = true;if (md === this._value) return;this._value = md;this.removeAttribute('invalid');this._writeOut(true);}_writeOut(announce) {if (this._ta) {this._writing = true;try { TEXTAREA_VALUE.set.call(this._ta, this._value); } finally { this._writing = false; }if (announce) this._ta.dispatchEvent(new Event('input', { bubbles: true }));} else {if (this._internals) this._internals.setFormValue(this._value);if (announce) this._fire('input');}}_fire(type) {if (this._ta) this._ta.dispatchEvent(new Event(type, { bubbles: true }));else this.dispatchEvent(new Event(type, { bubbles: true }));}// ---- rendering + selection ----------------------------------------------------------------_render(force) {const frag = renderDoc(this._doc);if (!force) {const probe = document.createElement('div');probe.appendChild(frag.cloneNode(true));if (probe.innerHTML === this._ed.innerHTML) {// same markup: only hand the blocks their origin againArray.from(this._ed.children).forEach((el, k) => { if (this._doc[k]) el.__mdO = this._doc[k].o; });this._placeholder();return false;}}this._ed.replaceChildren(frag);this._placeholder();return true;}_placeholder() {const d = this._doc;const empty = d.length === 1 && d[0].t === 'p' && lenOf(d[0].items[0]) === 0;this._ed.classList.toggle('empty', empty);}_selection() {if (shadowSelectionOk) return this.shadowRoot.getSelection();return document.getSelection();}_hasFocus() { return this.shadowRoot.activeElement === this._ed; }_rawRange() {const sel = this._selection();if (!sel) return null;if (!shadowSelectionOk && sel.getComposedRanges) {const rs = sel.getComposedRanges({ shadowRoots: [this.shadowRoot] });if (!rs.length) return null;return { sc: rs[0].startContainer, so: rs[0].startOffset, ec: rs[0].endContainer, eo: rs[0].endOffset };}if (!sel.rangeCount) return null;const r = sel.getRangeAt(0);return { sc: r.startContainer, so: r.startOffset, ec: r.endContainer, eo: r.endOffset };}// the selection as linear model positions { a, b } (read from the current DOM), or null_rangeLin(fromDom) {const r = this._rawRange();if (!r || !this._ed.contains(r.sc) || !this._ed.contains(r.ec)) return null;const m1 = { node: r.sc, off: r.so }, m2 = { node: r.ec, off: r.eo };const raw = readDom(this._ed, { marks: [m1, m2] });const res = { a: Math.min(m1.lin, m2.lin), b: Math.max(m1.lin, m2.lin), doc: raw };return fromDom ? res : { a: res.a, b: res.b };}_point(lin) {const p = fromLin(this._doc, lin);const bel = this._ed.children[p.b];if (!bel) return [this._ed, this._ed.childNodes.length];const iel = (bel.tagName === 'UL' || bel.tagName === 'OL') ? bel.children[p.i] || bel : bel;let o = p.o;const walker = document.createTreeWalker(iel, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);let n = walker.nextNode();let lastBr = null;while (n) {if (n.nodeType === 3) {if (o <= n.data.length) return [n, o];o -= n.data.length;} else if (n.tagName === 'BR') {if (o === 0) return [n.parentNode, Array.prototype.indexOf.call(n.parentNode.childNodes, n)];o -= 1;lastBr = n;}n = walker.nextNode();}if (lastBr && lastBr === iel.lastChild) return [iel, iel.childNodes.length - 1];return [iel, iel.childNodes.length];}_setSel(a, b) {const sel = this._selection() || document.getSelection();const [n1, o1] = this._point(a);const [n2, o2] = this._point(b == null ? a : b);try { sel.setBaseAndExtent(n1, o1, n2, o2); } catch (err) { /* detached */ }this._saved = { a, b: b == null ? a : b };}_snap() {const r = this._saved || { a: 0, b: 0 };return { doc: clone(this._doc), a: r.a, b: r.b };}_pushUndo(s) {this._undo.push(s);if (this._undo.length > 200) this._undo.shift();this._redo = [];}// ---- the one path every change takes -----------------------------------------------------_apply(doc, a, b) {this._doc = edDoc(doc);this._render(true);const n = docLen(this._doc);a = Math.min(Math.max(0, a), n);b = b == null ? a : Math.min(Math.max(0, b), n);if (this._hasFocus() || document.activeElement === this) this._setSel(a, b);else this._saved = { a, b };this._commit();this._lastSnap = this._snap();this._updateBar();}_op(fn) {if (this._ed.contentEditable !== 'true') return;const r = this._rangeLin() || this._saved || { a: docLen(this._doc), b: docLen(this._doc) };// undo brings back the text AND the selection this action was made onthis._pushUndo({ doc: (this._lastSnap || this._snap()).doc, a: r.a, b: r.b });this._lastTypeAt = 0;const doc = clone(this._doc);const res = fn(doc, r.a, r.b);this._apply(res.doc || doc, res.a, res.b);}// after the browser changed the DOM itself (typing, native deletes): read it back, apply the// input rules, normalise, re-render only if the markup is not what the model renders_sync(inputType, data) {const r = this._rangeLin(true);const doc0 = r ? r.doc : readDom(this._ed, {});let a = r ? r.a : 0, b = r ? r.b : 0;const now = Date.now();const typing = inputType === 'insertText' || inputType === 'insertCompositionText';if (!(typing && now - this._lastTypeAt < 1500) && this._lastSnap) this._pushUndo(this._lastSnap);this._lastTypeAt = typing ? now : 0;let doc = edDoc(doc0);// a pending style (toolbar / shortcut with a collapsed caret, or after an input rule)if (this._pending && typing && a === b && a > this._pending.at) {const pd = this._pending;eachInRange(doc, pd.at, a, (chars, f, t) => {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; }});pd.at = a;}if (inputType === 'insertText' && data && a === b) {const res = this._rules(doc, a, data);if (res) {this._pushUndo(this._snapOf(doc, a));doc = res.doc;a = b = res.a;this._pending = res.pending ? Object.assign({ at: a }, res.pending) : this._pending;}}this._doc = edDoc(doc);const changed = this._render(false);if (changed) this._setSel(a, b);else this._saved = { a, b };this._commit();this._lastSnap = this._snap();this._updateBar();}_snapOf(doc, a) { return { doc: clone(edDoc(doc)), a, b: a }; }// ---- input rules: Markdown typed → formatted ---------------------------------------------_rules(doc0, a, data) {const doc = clone(doc0);const p = fromLin(doc, a);const blk = doc[p.b];if (blk.t === 'code') return null;const chars = toChars(blk.items[p.i]);const before = chars.slice(0, p.o);const text = before.map((c) => c.x).join('');// typed markers must be plain text (a bare URL being typed counts as text)const plain = (f, t) => before.slice(f, t).every((c) => !c.c && (!c.h || c.a));let m;if (blk.t === 'p' && !text.includes(NL) && plain(0, text.length)) {const rest = fromChars(chars.slice(p.o));const base = a - p.o;if ((m = /^(#{1,6}) $/.exec(text))) { doc[p.b] = block('h', [rest], { level: m[1].length }); return { doc, a: base }; }if (/^[-*+] $/.test(text)) { doc[p.b] = block('ul', [rest]); return { doc, a: base }; }if ((m = /^(\d{1,9})[.)] $/.exec(text))) { doc[p.b] = block('ol', [rest], { start: Number(m[1]) }); return { doc, a: base }; }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 }; }}const line0 = text.lastIndexOf(NL) + 1;const line = text.slice(line0);const next = chars[p.o] ? chars[p.o].x : '';const apply = (start, openLen, content, closeLen, attr, href) => {const s0 = line0 + start;if (!plain(s0, text.length)) return null;const inner = before.slice(s0 + openLen, text.length - closeLen);for (const c of inner) {if (c.a) { c.h = null; c.a = false; }if (attr === 'c') Object.assign(c, { c: true, s: false, e: false });else if (attr === 'h') Object.assign(c, { h: href, a: false, s: false, e: false, c: false });else c[attr] = true;}const out = before.slice(0, s0).concat(inner, chars.slice(p.o));blk.items[p.i] = fromChars(out);const na = a - openLen - closeLen - (text.length - closeLen - (s0 + openLen) - inner.length);return { doc, a: na, pending: { s: false, e: false, c: false } };};if (data === '`' && (m = /(^|[^`\\])`([^`]+)`$/.exec(line)) && m[2].trim()) {return apply(m.index + m[1].length, 1, m[2], 1, 'c');}// * and ** only open at the start of a word (2*3*4 stays literal)if (data === '*' && (m = /(^|[^\w*\\])\*\*([^*\s](?:[^*]*[^*\s])?)\*\*$/.exec(line))) {return apply(m.index + m[1].length, 2, m[2], 2, 's');}if (data === '*' && (m = /(^|[^\w*\\])\*([^*\s](?:[^*]*[^*\s])?)\*$/.exec(line))) {return apply(m.index + m[1].length, 1, m[2], 1, 'e');}if (data === '_' && !isAlnumC(next.charCodeAt(0) || 32) && (m = /(^|[^\w_\\])__([^_\s](?:[^_]*[^_\s])?)__$/.exec(line))) {return apply(m.index + m[1].length, 2, m[2], 2, 's');}if (data === '_' && !isAlnumC(next.charCodeAt(0) || 32) && (m = /(^|[^\w_\\])_([^_\s](?:[^_]*[^_\s])?)_$/.exec(line))) {return apply(m.index + m[1].length, 1, m[2], 1, 'e');}if (data === ')' && (m = /\[([^\[\]\n]+)\]\(([^()\s]+)\)$/.exec(line))) {const h = safeHref(m[2]);if (h) {const res = apply(m.index, 1, m[1], m[2].length + 3, 'h', h);if (res) res.pending = null;return res;}}return null;}// ---- browser input events --------------------------------------------------------------------_onBeforeInput(e) {if (this._ed.contentEditable !== 'true') { e.preventDefault(); return; }const t = e.inputType;const r = this._rangeLin();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; })();const cancel = () => { e.preventDefault(); };switch (t) {case 'insertParagraph':cancel();this._op((doc, a, b) => { delRange(doc, a, b); return { a: opEnter(doc, a) }; });return;case 'insertLineBreak':cancel();this._op((doc, a, b) => {delRange(doc, a, b);const p = fromLin(doc, a);if (doc[p.b].t === 'h') return { a: opEnter(doc, a) };return { a: insChars(doc, a, plainChars(NL)) };});return;case 'historyUndo': cancel(); this.undo(); return;case 'historyRedo': cancel(); this.redo(); return;case 'formatBold': cancel(); this._toggle('s'); return;case 'formatItalic': cancel(); this._toggle('e'); return;case 'insertFromDrop': case 'deleteByDrag': case 'insertFromPaste': case 'insertFromPasteAsQuotation': case 'insertFromYank':cancel(); return;default:if (t.startsWith('format')) { cancel(); return; }}if (!r) return;if (t.startsWith('delete')) {if (r.a !== r.b) {if (multi) { cancel(); this._op((doc, a, b) => { delRange(doc, a, b); return { a }; }); }return;}const p = fromLin(this._doc, r.a);const L = lenOf(this._doc[p.b].items[p.i]);const backward = t.includes('Backward');if (backward && p.o === 0 && (p.b > 0 || p.i > 0 || this._doc[p.b].t !== 'p')) {cancel();this._op((doc, a) => ({ a: opBackspaceAtStart(doc, a) }));} else if (!backward && p.o === L) {cancel();this._op((doc, a) => ({ a: opDeleteAtEnd(doc, a) }));} else if (backward && p.o === 0) {cancel();}return;}if ((t === 'insertText' || t === 'insertReplacementText') && multi) {cancel();const data = e.data != null ? e.data : (e.dataTransfer && e.dataTransfer.getData('text/plain')) || '';this._op((doc, a, b) => { delRange(doc, a, b); return { a: insChars(doc, a, plainChars(data)) }; });}}_onKey(e) {const mod = MAC ? e.metaKey : e.ctrlKey;if (this._ed.contentEditable !== 'true') return;if (mod && e.key === 'Enter') {e.preventDefault();const f = this.form || this.closest('form');if (f) { if (f.requestSubmit) f.requestSubmit(); else f.submit(); }return;}if (!mod) return;const k = e.key.toLowerCase();let act = null;if (!e.altKey && !e.shiftKey && k === 'b') act = () => this._toggle('s');else if (!e.altKey && !e.shiftKey && k === 'i') act = () => this._toggle('e');else if (!e.altKey && !e.shiftKey && k === 'e') act = () => this._toggle('c');else if (!e.altKey && !e.shiftKey && k === 'k') act = () => this._linkOpen();else if (!e.altKey && !e.shiftKey && k === 'z') act = () => this.undo();else if (!e.altKey && ((e.shiftKey && k === 'z') || (!e.shiftKey && k === 'y'))) act = () => this.redo();else if (e.altKey && !e.shiftKey && ['0', '1', '2', '3'].includes(e.key)) act = () => this._heading(Number(e.key));else if (e.altKey && !e.shiftKey && e.code === 'KeyC') act = () => this._block('code');else if (e.shiftKey && !e.altKey && e.code === 'Digit8') act = () => this._block('ul');else if (e.shiftKey && !e.altKey && e.code === 'Digit7') act = () => this._block('ol');if (act) { e.preventDefault(); act(); }}_barKey(e) {if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight' && e.key !== 'Home' && e.key !== 'End') return;const bs = Array.from(this._bar.querySelectorAll('button'));let k = bs.indexOf(this.shadowRoot.activeElement);if (k < 0) return;e.preventDefault();k = e.key === 'Home' ? 0 : e.key === 'End' ? bs.length - 1 : (k + (e.key === 'ArrowRight' ? 1 : -1) + bs.length) % bs.length;bs.forEach((b, j) => { b.tabIndex = j === k ? 0 : -1; });bs[k].focus();}// ---- paste / copy / drop ---------------------------------------------------------------_fragmentOf(dt, inCode) {const htmlData = dt.getData('text/html');const text = dt.getData('text/plain');if (inCode) return { code: text || (htmlData ? new DOMParser().parseFromString(htmlData, 'text/html').body.textContent : '') };if (htmlData && htmlData.trim()) {// inert: a parsed document runs no script and loads nothing; only the subset is readconst d = new DOMParser().parseFromString(htmlData, 'text/html');return { doc: readDom(d.body, { html: true }) };}const pb = [];const ranges = [];for (const b of markdownBlocks(text || '', ranges)) pb.push(fromParsed(b, ranges[pb.length].level));return { doc: pb };}_insertFragment(frag) {this._op((doc, a, b) => {delRange(doc, a, b);if (frag.code != null) return { a: insChars(doc, a, plainChars(frag.code.replace(/\r\n?/g, NL))) };const blocks = edDoc(frag.doc || []).filter((x) => x.t === 'code' || x.items.some((it) => lenOf(it) > 0));if (!blocks.length) return { a };if (blocks.length === 1 && blocks[0].t === 'p') return { a: insChars(doc, a, toChars(blocks[0].items[0])) };const p = fromLin(doc, a);const blk = doc[p.b];// split the block at the caret and put the pasted blocks between the halvesconst chars = toChars(blk.items[p.i]);const headItems = blk.items.slice(0, p.i).concat([fromChars(chars.slice(0, p.o))]);const tailItems = [fromChars(chars.slice(p.o))].concat(blk.items.slice(p.i + 1));const head = Object.assign({}, blk, { items: headItems });const tail = Object.assign({}, blk, { items: tailItems, o: -1 });const keep = (x) => x.items.some((it) => lenOf(it) > 0);const repl = [].concat(keep(head) ? [head] : [], blocks, keep(tail) || !keep(head) ? [tail] : []);doc.splice(p.b, 1, ...repl);// the caret goes to the end of the last pasted blockconst lastB = p.b + (keep(head) ? 1 : 0) + blocks.length - 1;const lastI = doc[lastB].items.length - 1;return { a: toLin(doc, { b: lastB, i: lastI, o: lenOf(doc[lastB].items[lastI]) }) };});}_inCode() {const r = this._rangeLin() || this._saved;if (!r) return false;return this._doc[fromLin(this._doc, r.a).b].t === 'code';}_onPaste(e) {e.preventDefault();if (!e.clipboardData || this._ed.contentEditable !== 'true') return;this._insertFragment(this._fragmentOf(e.clipboardData, this._inCode()));}_onDrop(e) {e.preventDefault();if (!e.dataTransfer || this._ed.contentEditable !== 'true') return;let pos = null;if (document.caretPositionFromPoint) {try { pos = document.caretPositionFromPoint(e.clientX, e.clientY, { shadowRoots: [this.shadowRoot] }); } catch (err) { pos = null; }}this._ed.focus();if (pos && this._ed.contains(pos.offsetNode)) {const sel = this._selection() || document.getSelection();try { sel.setBaseAndExtent(pos.offsetNode, pos.offset, pos.offsetNode, pos.offset); } catch (err) { /* keep */ }}this._insertFragment(this._fragmentOf(e.dataTransfer, this._inCode()));}_onCopy(e, cut) {const r = this._rangeLin();if (!r || r.a === r.b || !e.clipboardData) return;e.preventDefault();const part = this._slice(r.a, r.b);const md = serialize(part, null);const div = document.createElement('div');div.appendChild(renderDoc(part));e.clipboardData.setData('text/plain', md);e.clipboardData.setData('text/html', div.innerHTML);if (cut) this._op((doc, a, b) => { delRange(doc, a, b); return { a }; });}_slice(a, b) {const doc = clone(this._doc);delRange(doc, b, docLen(doc));delRange(doc, 0, a);return edDoc(doc.map((x) => Object.assign(x, { o: -1 })));}// ---- toolbar actions ---------------------------------------------------------------------_tool(id) {if (this._ed.contentEditable !== 'true') return;if (id === 'bold') this._toggle('s');else if (id === 'italic') this._toggle('e');else if (id === 'code') this._toggle('c');else if (id === 'link') this._linkOpen();else if (id === 'heading') {const r = this._rangeLin() || this._saved || { a: 0 };
Only the first lines are shown.
Branches
- mainmain branch
Latest commits
- 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