@oix1987/yjd 2.0.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,228 @@
1
+ import Module from '../core/module.js';
2
+
3
+ /**
4
+ * @mention module — trigger-based autocomplete that inserts a token carrying an
5
+ * id, so the serialized HTML/Markdown can tell the server who was tagged.
6
+ *
7
+ * new Editor(el, {
8
+ * mention: {
9
+ * trigger: '@',
10
+ * source: async (query) => [{ id, name, avatar_url }],
11
+ * renderItem: (item) => `<img src="${item.avatar_url}"> ${item.name}`,
12
+ * // optional extra triggers, e.g. '#' for task refs:
13
+ * triggers: [{ char: '#', source: async (q) => [...] }]
14
+ * }
15
+ * })
16
+ *
17
+ * Token HTML: <span class="mention" data-id="ID" data-trigger="@"
18
+ * contenteditable="false">@Name</span>
19
+ * → getMarkdown() emits `@[Name](id)`. Fires editor.on('mention:select', item).
20
+ */
21
+ export default class Mention extends Module {
22
+ // --rte-* theme vars copied onto the portaled menu when it opens.
23
+ static THEME_VARS = ['--rte-accent', '--rte-accent-ink', '--rte-accent-weak', '--rte-ink', '--rte-muted', '--rte-border', '--rte-bg', '--rte-radius-md', '--rte-shadow'];
24
+
25
+ constructor(editor, options = {}) {
26
+ super(editor, options);
27
+ this.isOpen = false;
28
+ this.activeIndex = 0;
29
+ this.items = [];
30
+ this.sources = this._buildSources();
31
+ this.buildMenu();
32
+ this.bindEvents();
33
+ }
34
+
35
+ _buildSources() {
36
+ const cfg = this.editor.options.mention || this.options || {};
37
+ const map = {};
38
+ const renderItem = cfg.renderItem;
39
+ if (typeof cfg.source === 'function') {
40
+ map[cfg.trigger || '@'] = { source: cfg.source, renderItem: cfg.renderItem || renderItem };
41
+ }
42
+ (cfg.triggers || []).forEach((t) => {
43
+ if (t && t.char && typeof t.source === 'function') {
44
+ map[t.char] = { source: t.source, renderItem: t.renderItem || renderItem };
45
+ }
46
+ });
47
+ return map;
48
+ }
49
+
50
+ get enabled() { return Object.keys(this.sources).length > 0; }
51
+
52
+ buildMenu() {
53
+ const menu = document.createElement('div');
54
+ menu.className = 'yjd-mention-menu';
55
+ menu.setAttribute('role', 'listbox');
56
+ menu.style.display = 'none';
57
+ this.menu = menu;
58
+ document.body.appendChild(menu);
59
+ }
60
+
61
+ bindEvents() {
62
+ if (!this.enabled) return;
63
+ this._onInput = () => this.handleInput();
64
+ this.editor.editor.addEventListener('input', this._onInput);
65
+
66
+ this._onKeydown = (e) => {
67
+ if (!this.isOpen) return;
68
+ // Stop here (capture phase) so an outer Enter-to-submit handler doesn't
69
+ // also fire once choose() closes the menu.
70
+ const handled = ['ArrowDown', 'ArrowUp', 'Enter', 'Tab', 'Escape'].includes(e.key);
71
+ if (handled) { e.preventDefault(); e.stopPropagation(); }
72
+ if (e.key === 'ArrowDown') this.move(1);
73
+ else if (e.key === 'ArrowUp') this.move(-1);
74
+ else if (e.key === 'Enter' || e.key === 'Tab') this.choose(this.activeIndex);
75
+ else if (e.key === 'Escape') this.close();
76
+ };
77
+ this.editor.editor.addEventListener('keydown', this._onKeydown, true);
78
+
79
+ this._onDocPointer = (e) => { if (this.isOpen && !this.menu.contains(e.target)) this.close(); };
80
+ document.addEventListener('pointerdown', this._onDocPointer, true);
81
+ }
82
+
83
+ handleInput() {
84
+ const sel = window.getSelection();
85
+ if (!sel || !sel.isCollapsed || !sel.rangeCount) return this.close();
86
+ const range = sel.getRangeAt(0);
87
+ const node = range.startContainer;
88
+ if (node.nodeType !== Node.TEXT_NODE) return this.close();
89
+
90
+ const triggers = Object.keys(this.sources).map((c) => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('');
91
+ const before = node.textContent.slice(0, range.startOffset);
92
+ const m = before.match(new RegExp(`(?:^|\\s)([${triggers}])([^\\s${triggers}]*)$`));
93
+ if (!m) return this.close();
94
+
95
+ this.char = m[1];
96
+ this.query = m[2];
97
+ this.node = node;
98
+ this.start = range.startOffset - this.query.length - 1; // index of trigger char
99
+ this._loadFor(range);
100
+ }
101
+
102
+ _loadFor(range) {
103
+ const src = this.sources[this.char];
104
+ if (!src) return this.close();
105
+ clearTimeout(this._t);
106
+ const q = this.query, char = this.char;
107
+ this._t = setTimeout(() => {
108
+ Promise.resolve(src.source(q)).then((items) => {
109
+ // Ignore stale responses (user kept typing / switched trigger).
110
+ if (this.char !== char || this.query !== q) return;
111
+ this.items = Array.isArray(items) ? items : [];
112
+ if (!this.items.length) return this.close();
113
+ this.activeIndex = 0;
114
+ this.render(src.renderItem);
115
+ this.open(range);
116
+ }).catch(() => this.close());
117
+ }, 120);
118
+ }
119
+
120
+ open(range) {
121
+ this.isOpen = true;
122
+ this.menu.style.display = 'block';
123
+ this._applyTheme();
124
+ const rect = range.getBoundingClientRect();
125
+ const x = rect.left || (range.startContainer.parentElement || this.editor.editor).getBoundingClientRect().left;
126
+ const y = rect.bottom || rect.top;
127
+ this.menu.style.left = `${Math.round(x + window.scrollX)}px`;
128
+ this.menu.style.top = `${Math.round(y + window.scrollY + 6)}px`;
129
+ const mh = this.menu.offsetHeight;
130
+ if (rect.bottom + mh + 8 > window.innerHeight) {
131
+ this.menu.style.top = `${Math.round(rect.top + window.scrollY - mh - 6)}px`;
132
+ }
133
+ }
134
+
135
+ close() {
136
+ if (!this.isOpen) return;
137
+ this.isOpen = false;
138
+ this.menu.style.display = 'none';
139
+ }
140
+
141
+ /**
142
+ * The menu is portaled to <body>, so it can't inherit the editor's --rte-*
143
+ * theme vars. Copy them across when opening so a themed editor themes its
144
+ * mention menu too (no need to re-declare the vars on .yjd-mention-menu).
145
+ */
146
+ _applyTheme() {
147
+ const root = this.editor.wrapper || this.editor.root;
148
+ if (!root) return;
149
+ const cs = getComputedStyle(root);
150
+ Mention.THEME_VARS.forEach((v) => {
151
+ const val = cs.getPropertyValue(v);
152
+ if (val) this.menu.style.setProperty(v, val.trim());
153
+ });
154
+ }
155
+
156
+ move(d) {
157
+ this.activeIndex = (this.activeIndex + d + this.items.length) % this.items.length;
158
+ [...this.menu.children].forEach((el, i) => {
159
+ el.classList.toggle('active', i === this.activeIndex);
160
+ el.setAttribute('aria-selected', i === this.activeIndex ? 'true' : 'false');
161
+ });
162
+ }
163
+
164
+ render(renderItem) {
165
+ this.menu.innerHTML = '';
166
+ this.items.forEach((item, i) => {
167
+ const el = document.createElement('button');
168
+ el.type = 'button';
169
+ el.className = 'yjd-mention-item' + (i === this.activeIndex ? ' active' : '');
170
+ el.setAttribute('role', 'option');
171
+ el.setAttribute('aria-selected', i === this.activeIndex ? 'true' : 'false');
172
+ const label = item.name || item.label || item.id || '';
173
+ // Default row: avatar (or an item.icon for special entries like "@all"),
174
+ // then the name. Apps only need a custom renderItem for richer layouts.
175
+ const media = item.avatar_url
176
+ ? `<img class="yjd-mention-avatar" src="${item.avatar_url}" alt="">`
177
+ : (item.icon ? `<span class="yjd-mention-ico">${item.icon}</span>` : '');
178
+ el.innerHTML = typeof renderItem === 'function'
179
+ ? renderItem(item)
180
+ : `${media}<span class="yjd-mention-name">${this.char}${label}</span>`;
181
+ el.addEventListener('pointerdown', (e) => { e.preventDefault(); this.choose(i); });
182
+ this.menu.appendChild(el);
183
+ });
184
+ }
185
+
186
+ choose(index) {
187
+ const item = this.items[index];
188
+ if (!item) return this.close();
189
+ const name = item.name || item.label || item.id || '';
190
+ try {
191
+ const node = this.node;
192
+ const sel = window.getSelection();
193
+ const del = document.createRange();
194
+ del.setStart(node, this.start);
195
+ del.setEnd(node, this.start + this.query.length + 1);
196
+ del.deleteContents();
197
+
198
+ const span = document.createElement('span');
199
+ span.className = 'mention';
200
+ span.setAttribute('data-id', String(item.id != null ? item.id : ''));
201
+ span.setAttribute('data-trigger', this.char);
202
+ span.setAttribute('contenteditable', 'false');
203
+ span.textContent = this.char + name;
204
+ del.insertNode(span);
205
+
206
+ const space = document.createTextNode(' ');
207
+ span.after(space);
208
+ const caret = document.createRange();
209
+ caret.setStart(space, 1);
210
+ caret.collapse(true);
211
+ sel.removeAllRanges();
212
+ sel.addRange(caret);
213
+ } catch (e) { /* node moved */ }
214
+
215
+ this.close();
216
+ this.editor.focus();
217
+ if (typeof this.editor.onContentChange === 'function') this.editor.onContentChange();
218
+ this.editor.emit('mention:select', item);
219
+ }
220
+
221
+ destroy() {
222
+ if (this._onInput) this.editor.editor.removeEventListener('input', this._onInput);
223
+ if (this._onKeydown) this.editor.editor.removeEventListener('keydown', this._onKeydown, true);
224
+ if (this._onDocPointer) document.removeEventListener('pointerdown', this._onDocPointer, true);
225
+ if (this.menu && this.menu.parentNode) this.menu.parentNode.removeChild(this.menu);
226
+ super.destroy();
227
+ }
228
+ }
@@ -60,10 +60,12 @@ export default class SlashMenu extends Module {
60
60
  // Keyboard interaction while open (capture so we beat other handlers).
61
61
  this._onKeydown = (e) => {
62
62
  if (!this.isOpen) return;
63
- if (e.key === 'ArrowDown') { e.preventDefault(); this.move(1); }
64
- else if (e.key === 'ArrowUp') { e.preventDefault(); this.move(-1); }
65
- else if (e.key === 'Enter') { e.preventDefault(); this.choose(this.activeIndex); }
66
- else if (e.key === 'Escape') { e.preventDefault(); this.close(); }
63
+ const handled = ['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(e.key);
64
+ if (handled) { e.preventDefault(); e.stopPropagation(); }
65
+ if (e.key === 'ArrowDown') this.move(1);
66
+ else if (e.key === 'ArrowUp') this.move(-1);
67
+ else if (e.key === 'Enter') this.choose(this.activeIndex);
68
+ else if (e.key === 'Escape') this.close();
67
69
  };
68
70
  this.editor.editor.addEventListener('keydown', this._onKeydown, true);
69
71
 
@@ -49,6 +49,33 @@ class Toolbar extends Module {
49
49
  ],
50
50
  toolbar2: []
51
51
  };
52
+ } else if (options.toolbar === 'full') {
53
+ // Explicit full preset == the defaults.
54
+ this.options = { ...Toolbar.DEFAULTS, ...options };
55
+ } else if (options.toolbar === 'compact') {
56
+ // One tidy row of the essentials — good for comment boxes.
57
+ this.options = {
58
+ container: null,
59
+ toolbar1: [
60
+ { group: 'text-format', items: ['bold', 'italic', 'underline'] },
61
+ { group: 'link', items: ['link'] },
62
+ { group: 'paragraph-ops', items: ['list'] },
63
+ { group: 'insert', items: ['image', 'emoji'] },
64
+ { group: 'more', items: ['more'] }
65
+ ],
66
+ toolbar2: []
67
+ };
68
+ } else if (options.toolbar && typeof options.toolbar === 'object' && Array.isArray(options.toolbar.exclude)) {
69
+ // Start from the defaults and drop the named items (and any group left empty).
70
+ const drop = new Set(options.toolbar.exclude);
71
+ const prune = (rows) => (rows || [])
72
+ .map(g => ({ ...g, items: g.items.filter(it => !drop.has(it)) }))
73
+ .filter(g => g.items.length && !(g.items.length === 1 && g.items[0] === 'more' && false));
74
+ this.options = {
75
+ container: null,
76
+ toolbar1: prune(Toolbar.DEFAULTS.toolbar1),
77
+ toolbar2: prune(Toolbar.DEFAULTS.toolbar2)
78
+ };
52
79
  } else if (options.toolbar1 || options.toolbar2) {
53
80
  // If specific toolbar1/toolbar2 config is provided, use it - COMPLETELY OVERRIDE DEFAULTS
54
81
  this.options = {
@@ -415,6 +442,7 @@ class Toolbar extends Module {
415
442
  'indent-decrease': 'Decrease Indent',
416
443
  'emoji': 'Insert Emoji',
417
444
  'image': 'Insert Image',
445
+ 'file': 'Attach File',
418
446
  'video': 'Insert Video',
419
447
  'tag': 'Insert Tag',
420
448
  'horizontal-rule': 'Insert Horizontal Rule',
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Serialization for yjd content — HTML <-> Markdown and HTML <-> JSON.
3
+ *
4
+ * Targeted at the HTML yjd emits (headings, inline marks, lists, links,
5
+ * images, blockquote, code, tables, hr, and mention tokens). Browser-only
6
+ * (uses the DOM). Zero dependencies.
7
+ *
8
+ * import { htmlToMarkdown, markdownToHtml, domToJson, jsonToHtml } from '.../serialize.js'
9
+ */
10
+
11
+ /* ============================ HTML -> Markdown ============================ */
12
+
13
+ export function htmlToMarkdown(html) {
14
+ const root = document.createElement('div');
15
+ root.innerHTML = html || '';
16
+ return blocksToMd(root, 0).replace(/\n{3,}/g, '\n\n').trim() + '\n';
17
+ }
18
+
19
+ function blocksToMd(parent, depth) {
20
+ let out = '';
21
+ parent.childNodes.forEach((node) => { out += nodeBlock(node, depth); });
22
+ return out;
23
+ }
24
+
25
+ function nodeBlock(node, depth) {
26
+ if (node.nodeType === 3) {
27
+ const t = node.textContent.replace(/\s+/g, ' ');
28
+ return t.trim() ? t + '\n\n' : '';
29
+ }
30
+ if (node.nodeType !== 1) return '';
31
+ const tag = node.tagName;
32
+ switch (tag) {
33
+ case 'H1': case 'H2': case 'H3': case 'H4': case 'H5': case 'H6':
34
+ return '#'.repeat(+tag[1]) + ' ' + inline(node) + '\n\n';
35
+ case 'P': case 'DIV': {
36
+ const c = inline(node);
37
+ return c.trim() ? c + '\n\n' : '';
38
+ }
39
+ case 'BLOCKQUOTE':
40
+ return inline(node).split('\n').map((l) => '> ' + l).join('\n') + '\n\n';
41
+ case 'PRE':
42
+ return '```\n' + node.textContent.replace(/\n$/, '') + '\n```\n\n';
43
+ case 'UL': return listToMd(node, depth, false) + '\n';
44
+ case 'OL': return listToMd(node, depth, true) + '\n';
45
+ case 'HR': return '---\n\n';
46
+ case 'TABLE': return tableToMd(node) + '\n';
47
+ case 'FIGURE': return blocksToMd(node, depth);
48
+ case 'IMG': return imgToMd(node) + '\n\n';
49
+ case 'BR': return '\n';
50
+ default:
51
+ return inline(node) + '\n\n';
52
+ }
53
+ }
54
+
55
+ function listToMd(node, depth, ordered) {
56
+ let out = '', i = 1;
57
+ node.childNodes.forEach((li) => {
58
+ if (li.nodeType !== 1 || li.tagName !== 'LI') return;
59
+ const marker = ordered ? (i++) + '. ' : '- ';
60
+ const pad = ' '.repeat(depth);
61
+ let text = '', nested = '';
62
+ li.childNodes.forEach((ch) => {
63
+ if (ch.nodeType === 1 && (ch.tagName === 'UL' || ch.tagName === 'OL')) {
64
+ nested += listToMd(ch, depth + 1, ch.tagName === 'OL');
65
+ } else {
66
+ text += inlineNode(ch);
67
+ }
68
+ });
69
+ out += pad + marker + text.trim() + '\n' + nested;
70
+ });
71
+ return out;
72
+ }
73
+
74
+ function tableToMd(node) {
75
+ const rows = [...node.querySelectorAll('tr')];
76
+ if (!rows.length) return '';
77
+ const cells = (r) => [...r.children].map((c) => inline(c).replace(/\|/g, '\\|').trim());
78
+ const head = cells(rows[0]);
79
+ let out = '| ' + head.join(' | ') + ' |\n| ' + head.map(() => '---').join(' | ') + ' |\n';
80
+ rows.slice(1).forEach((r) => { out += '| ' + cells(r).join(' | ') + ' |\n'; });
81
+ return out;
82
+ }
83
+
84
+ function imgToMd(node) {
85
+ return `![${node.getAttribute('alt') || ''}](${node.getAttribute('src') || ''})`;
86
+ }
87
+
88
+ function inline(node) {
89
+ let out = '';
90
+ node.childNodes.forEach((ch) => { out += inlineNode(ch); });
91
+ return out;
92
+ }
93
+
94
+ function inlineNode(node) {
95
+ if (node.nodeType === 3) return node.textContent;
96
+ if (node.nodeType !== 1) return '';
97
+ const tag = node.tagName;
98
+ if (node.classList && node.classList.contains('mention')) {
99
+ const id = node.getAttribute('data-id') || '';
100
+ const name = (node.textContent || '').replace(/^[@#]/, '');
101
+ const trig = (node.textContent || '@')[0];
102
+ return `${trig}[${name}](${id})`;
103
+ }
104
+ if (node.classList && node.classList.contains('yjd-file-chip')) {
105
+ const url = node.getAttribute('href') || '';
106
+ const nameEl = node.querySelector ? node.querySelector('.yjd-file-name') : null;
107
+ const name = node.getAttribute('data-name') || (nameEl && nameEl.textContent) || 'file';
108
+ const size = node.getAttribute('data-size') || '';
109
+ return `[${size ? `${name} (${size})` : name}](${url})`;
110
+ }
111
+ switch (tag) {
112
+ case 'B': case 'STRONG': return '**' + inline(node) + '**';
113
+ case 'I': case 'EM': return '*' + inline(node) + '*';
114
+ case 'S': case 'STRIKE': case 'DEL': return '~~' + inline(node) + '~~';
115
+ case 'U': return '<u>' + inline(node) + '</u>';
116
+ case 'CODE': return '`' + node.textContent + '`';
117
+ case 'A': return '[' + inline(node) + '](' + (node.getAttribute('href') || '') + ')';
118
+ case 'IMG': return imgToMd(node);
119
+ case 'BR': return ' \n';
120
+ default: return inline(node); // spans (colour/font) → keep text only
121
+ }
122
+ }
123
+
124
+ /* ============================ Markdown -> HTML ============================ */
125
+
126
+ export function markdownToHtml(md) {
127
+ const lines = (md || '').replace(/\r\n/g, '\n').split('\n');
128
+ let html = '', i = 0;
129
+ const isList = (l) => /^\s*([-*+]|\d+\.)\s+/.test(l);
130
+ while (i < lines.length) {
131
+ const line = lines[i];
132
+ if (/^\s*$/.test(line)) { i++; continue; }
133
+ if (/^---+$/.test(line.trim())) { html += '<hr>'; i++; continue; }
134
+ const h = line.match(/^(#{1,6})\s+(.*)$/);
135
+ if (h) { html += `<h${h[1].length}>${inlineMd(h[2])}</h${h[1].length}>`; i++; continue; }
136
+ if (/^```/.test(line)) {
137
+ i++; let code = '';
138
+ while (i < lines.length && !/^```/.test(lines[i])) { code += lines[i] + '\n'; i++; }
139
+ i++; html += '<pre>' + escapeHtml(code.replace(/\n$/, '')) + '</pre>'; continue;
140
+ }
141
+ if (/^>\s?/.test(line)) {
142
+ const q = [];
143
+ while (i < lines.length && /^>\s?/.test(lines[i])) { q.push(lines[i].replace(/^>\s?/, '')); i++; }
144
+ html += '<blockquote>' + inlineMd(q.join(' ')) + '</blockquote>'; continue;
145
+ }
146
+ if (/^\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\|[\s:|-]+\|\s*$/.test(lines[i + 1])) {
147
+ const r = parseTable(lines, i); html += r.html; i = r.next; continue;
148
+ }
149
+ if (isList(line)) { const r = parseList(lines, i, 0); html += r.html; i = r.next; continue; }
150
+ const para = [line]; i++;
151
+ while (i < lines.length && !/^\s*$/.test(lines[i]) && !/^(#{1,6}\s|>|```)/.test(lines[i]) &&
152
+ !/^---+$/.test(lines[i].trim()) && !isList(lines[i])) { para.push(lines[i]); i++; }
153
+ html += '<p>' + inlineMd(para.join('\n').trim()) + '</p>';
154
+ }
155
+ return html;
156
+ }
157
+
158
+ function indentOf(l) { return (l.match(/^(\s*)/)[1] || '').length; }
159
+
160
+ function parseList(lines, start, baseIndent) {
161
+ const ordered = /^\s*\d+\./.test(lines[start]);
162
+ let i = start, html = '<' + (ordered ? 'ol' : 'ul') + '>';
163
+ while (i < lines.length) {
164
+ const l = lines[i];
165
+ if (/^\s*$/.test(l)) { i++; continue; }
166
+ const ind = indentOf(l);
167
+ const m = l.match(/^\s*([-*+]|\d+\.)\s+(.*)$/);
168
+ if (!m || ind < baseIndent) break;
169
+ if (ind > baseIndent) { // nested list belongs to previous <li>
170
+ const r = parseList(lines, i, ind);
171
+ html = html.replace(/<\/li>$/, r.html + '</li>');
172
+ i = r.next; continue;
173
+ }
174
+ html += '<li>' + inlineMd(m[2]) + '</li>';
175
+ i++;
176
+ }
177
+ return { html: html + '</' + (ordered ? 'ol' : 'ul') + '>', next: i };
178
+ }
179
+
180
+ function parseTable(lines, start) {
181
+ const row = (l) => l.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim());
182
+ const head = row(lines[start]);
183
+ let i = start + 2, body = '';
184
+ while (i < lines.length && /^\|.*\|\s*$/.test(lines[i])) {
185
+ body += '<tr>' + row(lines[i]).map((c) => `<td>${inlineMd(c)}</td>`).join('') + '</tr>';
186
+ i++;
187
+ }
188
+ const thead = '<tr>' + head.map((c) => `<td><b>${inlineMd(c)}</b></td>`).join('') + '</tr>';
189
+ return { html: `<table class="rich-editor-table"><tbody>${thead}${body}</tbody></table>`, next: i };
190
+ }
191
+
192
+ function inlineMd(s) {
193
+ // images, mentions, links, then marks. Order matters.
194
+ return s
195
+ .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, a, src) => `<img class="inserted-image" src="${attr(src)}" alt="${attr(a)}" style="max-width:100%;height:auto">`)
196
+ .replace(/([@#])\[([^\]]+)\]\(([^)]+)\)/g, (_, t, name, id) => `<span class="mention" data-id="${attr(id)}">${t}${escapeHtml(name)}</span>`)
197
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, t, href) => `<a href="${attr(href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(t)}</a>`)
198
+ .replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
199
+ .replace(/(^|[^*])\*([^*]+)\*/g, '$1<i>$2</i>')
200
+ .replace(/~~([^~]+)~~/g, '<s>$1</s>')
201
+ .replace(/`([^`]+)`/g, (_, c) => '<code>' + escapeHtml(c) + '</code>')
202
+ .replace(/\n/g, '<br>');
203
+ }
204
+
205
+ function escapeHtml(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
206
+ function attr(s) { return String(s).replace(/"/g, '&quot;').replace(/</g, '&lt;'); }
207
+
208
+ /* ============================== HTML <-> JSON ============================= */
209
+
210
+ export function domToJson(html) {
211
+ const root = document.createElement('div');
212
+ root.innerHTML = html || '';
213
+ return { type: 'doc', content: [...root.childNodes].map(nodeToJson).filter(Boolean) };
214
+ }
215
+
216
+ function nodeToJson(node) {
217
+ if (node.nodeType === 3) {
218
+ const text = node.textContent;
219
+ return text ? { text } : null;
220
+ }
221
+ if (node.nodeType !== 1) return null;
222
+ const obj = { tag: node.tagName.toLowerCase() };
223
+ if (node.attributes.length) {
224
+ obj.attrs = {};
225
+ for (const a of node.attributes) obj.attrs[a.name] = a.value;
226
+ }
227
+ const kids = [...node.childNodes].map(nodeToJson).filter(Boolean);
228
+ if (kids.length) obj.content = kids;
229
+ return obj;
230
+ }
231
+
232
+ export function jsonToHtml(json) {
233
+ const nodes = json && json.content ? json.content : (Array.isArray(json) ? json : []);
234
+ return nodes.map(jsonNodeToHtml).join('');
235
+ }
236
+
237
+ function jsonNodeToHtml(n) {
238
+ if (n == null) return '';
239
+ if (n.text != null) return escapeHtml(n.text);
240
+ if (!n.tag) return '';
241
+ const attrs = n.attrs
242
+ ? Object.entries(n.attrs).map(([k, v]) => ` ${k}="${attr(v)}"`).join('')
243
+ : '';
244
+ const inner = (n.content || []).map(jsonNodeToHtml).join('');
245
+ const VOID = new Set(['img', 'hr', 'br', 'input']);
246
+ if (VOID.has(n.tag)) return `<${n.tag}${attrs}>`;
247
+ return `<${n.tag}${attrs}>${inner}</${n.tag}>`;
248
+ }
package/lib/static.js ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * renderStatic — paint stored HTML into a read-only view that looks exactly
3
+ * like the editor's content area, without booting an editor.
4
+ *
5
+ * It sanitizes the HTML (same allowlist the editor uses on paste/setContent)
6
+ * and tags the host element with `.yjd-content`, the class the stylesheet uses
7
+ * to style typography, lists, tables, images, and mention tokens. Load the
8
+ * editor stylesheet (or StylesLoader.loadStyles()) on the page for it to match.
9
+ *
10
+ * import { renderStatic } from '@oix1987/yjd/core';
11
+ * renderStatic(post.body_html, document.querySelector('#post'));
12
+ *
13
+ * @param {string} html Stored HTML (untrusted — it is sanitized).
14
+ * @param {Element} [target] Element to render into. If omitted, a fresh
15
+ * <div.yjd-content> is created and returned.
16
+ * @returns {Element} the element the content was rendered into.
17
+ */
18
+ import { sanitizeHtml } from './utils/sanitize.js';
19
+
20
+ export function renderStatic(html, target) {
21
+ const safe = sanitizeHtml(html || '');
22
+ const el = target || document.createElement('div');
23
+ el.classList.add('yjd-content');
24
+ el.innerHTML = safe;
25
+ return el;
26
+ }
27
+
28
+ export default renderStatic;