autumnnote 1.0.0

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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +874 -0
  3. package/dist/autumnnote.css +1 -0
  4. package/dist/autumnnote.es.js +5888 -0
  5. package/dist/autumnnote.es.js.map +1 -0
  6. package/dist/autumnnote.umd.js +74 -0
  7. package/dist/autumnnote.umd.js.map +1 -0
  8. package/package.json +55 -0
  9. package/src/js/Context.js +497 -0
  10. package/src/js/core/dom.js +315 -0
  11. package/src/js/core/env.js +25 -0
  12. package/src/js/core/func.js +153 -0
  13. package/src/js/core/key.js +66 -0
  14. package/src/js/core/lists.js +121 -0
  15. package/src/js/core/markdown.js +294 -0
  16. package/src/js/core/range.js +194 -0
  17. package/src/js/core/sanitise.js +78 -0
  18. package/src/js/editing/History.js +205 -0
  19. package/src/js/editing/Style.js +329 -0
  20. package/src/js/editing/Table.js +59 -0
  21. package/src/js/editing/Typing.js +142 -0
  22. package/src/js/index.js +126 -0
  23. package/src/js/module/Buttons.js +300 -0
  24. package/src/js/module/Clipboard.js +460 -0
  25. package/src/js/module/CodeTooltip.js +428 -0
  26. package/src/js/module/Codeview.js +122 -0
  27. package/src/js/module/ContextMenu.js +470 -0
  28. package/src/js/module/Editor.js +528 -0
  29. package/src/js/module/EmojiDialog.js +726 -0
  30. package/src/js/module/FindReplace.js +440 -0
  31. package/src/js/module/Fullscreen.js +80 -0
  32. package/src/js/module/IconDialog.js +620 -0
  33. package/src/js/module/ImageDialog.js +208 -0
  34. package/src/js/module/ImageResizer.js +216 -0
  35. package/src/js/module/ImageTooltip.js +286 -0
  36. package/src/js/module/LinkDialog.js +204 -0
  37. package/src/js/module/LinkTooltip.js +242 -0
  38. package/src/js/module/Placeholder.js +44 -0
  39. package/src/js/module/ShortcutsDialog.js +141 -0
  40. package/src/js/module/Statusbar.js +238 -0
  41. package/src/js/module/TableTooltip.js +568 -0
  42. package/src/js/module/Toolbar.js +562 -0
  43. package/src/js/module/VideoDialog.js +263 -0
  44. package/src/js/module/VideoResizer.js +227 -0
  45. package/src/js/module/VideoTooltip.js +252 -0
  46. package/src/js/renderer.js +107 -0
  47. package/src/js/settings.js +134 -0
  48. package/src/styles/_variables.scss +48 -0
  49. package/src/styles/autumnnote.scss +1740 -0
  50. package/types/index.d.ts +324 -0
@@ -0,0 +1,428 @@
1
+ // CodeTooltip.js - Hover tooltip for <pre> code blocks inside the editor
2
+ // Shows a horizontal action bar above (or below) the hovered code block,
3
+ // consistent in appearance and interaction with ImageTooltip / TableTooltip.
4
+ import { createElement, on } from '../core/dom.js';
5
+
6
+ const SHOW_DELAY = 100;
7
+ const HIDE_DELAY = 180;
8
+
9
+ const ICONS = {
10
+ copy: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`,
11
+ wrapOn: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><path d="M3 12h15a3 3 0 0 1 0 6H3"/><polyline points="6 15 3 18 6 21"/></svg>`,
12
+ toParagraph:`<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 4v16"/><path d="M17 4v16"/><path d="M9 4h8a4 4 0 0 1 0 8H9V4z"/></svg>`,
13
+ deleteCode: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>`,
14
+ };
15
+
16
+ export class CodeTooltip {
17
+ /** @param {import('../Context.js').Context} context */
18
+ constructor(context) {
19
+ this.context = context;
20
+ this._el = null;
21
+ this._activePre = null;
22
+ this._showTimer = null;
23
+ this._hideTimer = null;
24
+ this._disposers = [];
25
+ this._copyBtn = null;
26
+ this._langSelect = null;
27
+ this._prismScript = null; // script element while Prism is loading
28
+ }
29
+
30
+ initialize() {
31
+ this._el = this._buildTooltip();
32
+ document.body.appendChild(this._el);
33
+ this._ensurePrism();
34
+
35
+ const editable = this.context.layoutInfo.editable;
36
+
37
+ this._disposers.push(
38
+ on(editable, 'mouseover', (e) => {
39
+ const pre = e.target.closest('pre');
40
+ if (pre && editable.contains(pre)) {
41
+ this._scheduleShow(pre);
42
+ }
43
+ }),
44
+ on(editable, 'mouseout', (e) => {
45
+ const to = e.relatedTarget;
46
+ if (!to || (!editable.contains(to) && !this._el.contains(to))) {
47
+ this._scheduleHide();
48
+ }
49
+ }),
50
+ on(document, 'click', (e) => {
51
+ if (this._activePre &&
52
+ !this._activePre.contains(e.target) &&
53
+ !this._el.contains(e.target)) {
54
+ this._hide();
55
+ }
56
+ }),
57
+ );
58
+
59
+ return this;
60
+ }
61
+
62
+ destroy() {
63
+ this._clearTimers();
64
+ this._disposers.forEach((d) => d());
65
+ this._disposers = [];
66
+ if (this._el && this._el.parentNode) this._el.parentNode.removeChild(this._el);
67
+ this._el = null;
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Build
72
+ // ---------------------------------------------------------------------------
73
+
74
+ _buildTooltip() {
75
+ const el = createElement('div', {
76
+ class: 'an-link-tooltip an-code-tooltip',
77
+ role: 'toolbar',
78
+ 'aria-label': 'Code block actions',
79
+ });
80
+ el.style.display = 'none';
81
+
82
+ // Label
83
+ this._label = createElement('span', { class: 'an-link-tooltip-url' });
84
+ this._label.textContent = 'Code';
85
+ el.appendChild(this._label);
86
+
87
+ el.appendChild(this._sep());
88
+
89
+ // Language selector
90
+ this._langSelect = createElement('select', {
91
+ class: 'an-code-lang-select',
92
+ title: 'Syntax Language',
93
+ 'aria-label': 'Syntax language',
94
+ });
95
+ const LANGUAGES = [
96
+ ['', 'Plain text'], ['javascript', 'JavaScript'], ['typescript', 'TypeScript'],
97
+ ['python', 'Python'], ['html', 'HTML'], ['css', 'CSS'], ['json', 'JSON'],
98
+ ['xml', 'XML'], ['bash', 'Bash / Shell'], ['sql', 'SQL'],
99
+ ['java', 'Java'], ['csharp', 'C#'], ['php', 'PHP'], ['ruby', 'Ruby'],
100
+ ['go', 'Go'], ['rust', 'Rust'], ['cpp', 'C++'], ['c', 'C'],
101
+ ['kotlin', 'Kotlin'], ['swift', 'Swift'],
102
+ ];
103
+ LANGUAGES.forEach(([value, label]) => {
104
+ const opt = createElement('option', { value });
105
+ opt.textContent = label;
106
+ this._langSelect.appendChild(opt);
107
+ });
108
+ this._disposers.push(on(this._langSelect, 'change', () => this._onLangChange()));
109
+ el.appendChild(this._langSelect);
110
+
111
+ el.appendChild(this._sep());
112
+
113
+ // Copy code
114
+ this._copyBtn = this._makeBtn(ICONS.copy, 'Copy Code', () => this._copyCode());
115
+
116
+ el.appendChild(this._copyBtn);
117
+
118
+ el.appendChild(this._sep());
119
+
120
+ // Toggle word-wrap
121
+ this._wrapBtn = this._makeBtn(ICONS.wrapOn, 'Toggle Word Wrap', () => this._toggleWrap());
122
+ el.appendChild(this._wrapBtn);
123
+
124
+ el.appendChild(this._sep());
125
+
126
+ // Convert to normal paragraph
127
+ el.appendChild(this._makeBtn(ICONS.toParagraph, 'Convert to Paragraph', () => this._toParagraph()));
128
+
129
+ el.appendChild(this._sep());
130
+
131
+ // Delete block
132
+ el.appendChild(this._makeBtn(ICONS.deleteCode, 'Delete Code Block', () => this._delete(), true));
133
+
134
+ // Keep tooltip alive while hovering it
135
+ this._disposers.push(
136
+ on(el, 'mouseenter', () => this._clearTimers()),
137
+ on(el, 'mouseleave', () => this._scheduleHide()),
138
+ );
139
+
140
+ return el;
141
+ }
142
+
143
+ _sep() {
144
+ return createElement('div', { class: 'an-link-tooltip-sep' });
145
+ }
146
+
147
+ /**
148
+ * @param {string} icon
149
+ * @param {string} title
150
+ * @param {Function} handler
151
+ * @param {boolean} [isDanger]
152
+ */
153
+ _makeBtn(icon, title, handler, isDanger = false) {
154
+ const btn = createElement('button', {
155
+ type: 'button',
156
+ class: isDanger ? 'an-link-tooltip-btn an-link-tooltip-btn--danger' : 'an-link-tooltip-btn',
157
+ title,
158
+ });
159
+ btn.innerHTML = icon;
160
+ this._disposers.push(on(btn, 'click', (e) => {
161
+ e.preventDefault();
162
+ e.stopPropagation();
163
+ handler();
164
+ }));
165
+ return btn;
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Show / Hide
170
+ // ---------------------------------------------------------------------------
171
+
172
+ _scheduleShow(pre) {
173
+ if (this._activePre === pre && this._el.style.display !== 'none') return;
174
+ clearTimeout(this._hideTimer);
175
+ this._hideTimer = null;
176
+ clearTimeout(this._showTimer);
177
+ this._showTimer = setTimeout(() => {
178
+ this._activePre = pre;
179
+ this._syncWrapBtn();
180
+ this._syncLangSelect();
181
+ this._show(pre);
182
+ }, SHOW_DELAY);
183
+ }
184
+
185
+ _scheduleHide() {
186
+ clearTimeout(this._showTimer);
187
+ this._showTimer = null;
188
+ if (this._hideTimer) return;
189
+ this._hideTimer = setTimeout(() => this._hide(), HIDE_DELAY);
190
+ }
191
+
192
+ _show(pre) {
193
+ this._el.style.display = 'flex';
194
+ this._positionNear(pre);
195
+ }
196
+
197
+ _hide() {
198
+ this._el.style.display = 'none';
199
+ this._activePre = null;
200
+ this._clearTimers();
201
+ }
202
+
203
+ _clearTimers() {
204
+ clearTimeout(this._showTimer);
205
+ clearTimeout(this._hideTimer);
206
+ this._showTimer = null;
207
+ this._hideTimer = null;
208
+ }
209
+
210
+ _positionNear(pre) {
211
+ const rect = pre.getBoundingClientRect();
212
+ const tipW = this._el.offsetWidth || 260;
213
+ const tipH = this._el.offsetHeight || 32;
214
+ const margin = 6;
215
+
216
+ // Prefer above the block; fall back to below
217
+ let top = rect.top - tipH - margin;
218
+ let left = rect.left + (rect.width - tipW) / 2;
219
+
220
+ if (top < margin) top = rect.bottom + margin;
221
+ if (left + tipW > window.innerWidth - margin) left = window.innerWidth - tipW - margin;
222
+ if (left < margin) left = margin;
223
+
224
+ this._el.style.top = `${top + window.scrollY}px`;
225
+ this._el.style.left = `${left + window.scrollX}px`;
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // Sync wrap-button active state
230
+ // ---------------------------------------------------------------------------
231
+
232
+ _syncWrapBtn() {
233
+ if (!this._activePre || !this._wrapBtn) return;
234
+ const wrapped = (this._activePre.style.whiteSpace || '').includes('pre-wrap')
235
+ || window.getComputedStyle(this._activePre).whiteSpace === 'pre-wrap';
236
+ this._wrapBtn.classList.toggle('active', wrapped);
237
+ this._wrapBtn.title = wrapped ? 'Disable Word Wrap' : 'Enable Word Wrap';
238
+ }
239
+
240
+ _syncLangSelect() {
241
+ if (!this._activePre || !this._langSelect) return;
242
+ const codeEl = this._activePre.querySelector('code');
243
+ const fromAttr = this._activePre.getAttribute('data-language') || '';
244
+ const fromClass = codeEl ? (codeEl.className.match(/language-(\S+)/) || [])[1] || '' : '';
245
+ this._langSelect.value = fromAttr || fromClass || '';
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // Actions
250
+ // ---------------------------------------------------------------------------
251
+
252
+ _copyCode() {
253
+ const pre = this._activePre;
254
+ if (!pre) return;
255
+ const text = pre.textContent || '';
256
+ if (navigator.clipboard) {
257
+ navigator.clipboard.writeText(text).then(() => this._flashCopied()).catch(() => {});
258
+ } else {
259
+ // Fallback for older browsers
260
+ const ta = document.createElement('textarea');
261
+ ta.value = text;
262
+ ta.style.cssText = 'position:fixed;opacity:0;top:0;left:0';
263
+ document.body.appendChild(ta);
264
+ ta.select();
265
+ try { document.execCommand('copy'); this._flashCopied(); } catch (_) {}
266
+ document.body.removeChild(ta);
267
+ }
268
+ }
269
+
270
+ _flashCopied() {
271
+ if (!this._copyBtn) return;
272
+ const original = this._copyBtn.innerHTML;
273
+ this._copyBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`;
274
+ this._copyBtn.classList.add('an-link-tooltip-btn--copied');
275
+ setTimeout(() => {
276
+ if (this._copyBtn) {
277
+ this._copyBtn.innerHTML = original;
278
+ this._copyBtn.classList.remove('an-link-tooltip-btn--copied');
279
+ }
280
+ }, 1400);
281
+ }
282
+
283
+ _toggleWrap() {
284
+ const pre = this._activePre;
285
+ if (!pre) return;
286
+ const isWrapped = pre.style.whiteSpace === 'pre-wrap';
287
+ pre.style.whiteSpace = isWrapped ? 'pre' : 'pre-wrap';
288
+ this._syncWrapBtn();
289
+ this.context.invoke('editor.afterCommand');
290
+ this._positionNear(pre);
291
+ }
292
+
293
+ _onLangChange() {
294
+ const pre = this._activePre;
295
+ if (!pre) return;
296
+ const lang = this._langSelect.value;
297
+
298
+ // Ensure a <code> child exists (Prism targets <pre><code class="language-xxx">)
299
+ let codeEl = pre.querySelector('code');
300
+ if (!codeEl) {
301
+ codeEl = document.createElement('code');
302
+ codeEl.innerHTML = pre.innerHTML;
303
+ pre.innerHTML = '';
304
+ pre.appendChild(codeEl);
305
+ }
306
+
307
+ codeEl.className = lang ? `language-${lang}` : '';
308
+ // Mirror language class on <pre> so Prism CSS theme targets it (pre[class*='language-'])
309
+ pre.className = lang ? `language-${lang}` : '';
310
+ if (lang) {
311
+ pre.setAttribute('data-language', lang);
312
+ } else {
313
+ pre.removeAttribute('data-language');
314
+ }
315
+
316
+ // Trigger Prism if available.
317
+ // contenteditable stores line breaks as <br> elements; Prism reads textContent
318
+ // which drops <br> entirely, collapsing all lines into one. Convert first.
319
+ const applyPrism = () => {
320
+ codeEl.querySelectorAll('br').forEach((br) => br.replaceWith('\n'));
321
+ window.Prism.highlightElement(codeEl);
322
+ this.context.invoke('editor.afterCommand');
323
+ };
324
+
325
+ if (lang) {
326
+ if (typeof window.Prism !== 'undefined') {
327
+ // Grammar already loaded — highlight immediately
328
+ if (window.Prism.languages[lang]) {
329
+ applyPrism();
330
+ return;
331
+ }
332
+ // Grammar not in core bundle — load the component script first
333
+ this._loadPrismComponent(lang, applyPrism);
334
+ return;
335
+ } else if (this._prismScript) {
336
+ // Prism core is still loading — highlight once it arrives, then load grammar if needed
337
+ this._prismScript.addEventListener('load', () => {
338
+ if (window.Prism.languages[lang]) {
339
+ applyPrism();
340
+ } else {
341
+ this._loadPrismComponent(lang, applyPrism);
342
+ }
343
+ }, { once: true });
344
+ return;
345
+ }
346
+ }
347
+
348
+ this.context.invoke('editor.afterCommand');
349
+ }
350
+
351
+ /**
352
+ * Loads Prism.js + CSS from CDN when options.codeHighlight is true.
353
+ * Called once at initialize time. Fire-and-forget; errors are silent.
354
+ */
355
+ _ensurePrism() {
356
+ if (!this.context.options.codeHighlight || window.Prism) return;
357
+ const cdn = this.context.options.codeHighlightCDN
358
+ || 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0';
359
+
360
+ const link = document.createElement('link');
361
+ link.rel = 'stylesheet';
362
+ link.href = `${cdn}/themes/prism-tomorrow.min.css`;
363
+ document.head.appendChild(link);
364
+
365
+ const script = document.createElement('script');
366
+ script.dataset.manual = ''; // prevent auto-highlight on load
367
+ script.src = `${cdn}/prism.min.js`;
368
+ this._prismScript = script;
369
+ script.addEventListener('load', () => { this._prismScript = null; }, { once: true });
370
+ document.head.appendChild(script);
371
+ }
372
+
373
+ /**
374
+ * Dynamically loads a Prism language component script if not already available.
375
+ * Prism core only bundles: markup, css, clike, javascript.
376
+ * All other grammars (bash, python, etc.) need a component file.
377
+ * @param {string} lang – Prism language slug (e.g. 'bash', 'python')
378
+ * @param {Function} cb – called once the grammar is ready
379
+ */
380
+ _loadPrismComponent(lang, cb) {
381
+ const cdn = this.context.options.codeHighlightCDN
382
+ || 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0';
383
+ const src = `${cdn}/components/prism-${lang}.min.js`;
384
+ // Avoid loading the same component twice
385
+ if (document.querySelector(`script[src="${src}"]`)) {
386
+ // Already in DOM — might still be loading; poll briefly then call cb
387
+ const poll = setInterval(() => {
388
+ if (window.Prism && window.Prism.languages[lang]) {
389
+ clearInterval(poll);
390
+ cb();
391
+ }
392
+ }, 50);
393
+ setTimeout(() => clearInterval(poll), 3000); // give up after 3s
394
+ return;
395
+ }
396
+ const s = document.createElement('script');
397
+ s.src = src;
398
+ s.addEventListener('load', cb, { once: true });
399
+ document.head.appendChild(s);
400
+ }
401
+
402
+ _toParagraph() {
403
+ const pre = this._activePre;
404
+ if (!pre) return;
405
+ const editable = this.context.layoutInfo.editable;
406
+ if (!editable) return;
407
+
408
+ // Replace <pre> with <p> preserving lines as <br>
409
+ const lines = (pre.textContent || '').split('\n');
410
+ const p = document.createElement('p');
411
+ lines.forEach((line, i) => {
412
+ if (i > 0) p.appendChild(document.createElement('br'));
413
+ p.appendChild(document.createTextNode(line));
414
+ });
415
+
416
+ pre.parentNode.replaceChild(p, pre);
417
+ this._hide();
418
+ this.context.invoke('editor.afterCommand');
419
+ }
420
+
421
+ _delete() {
422
+ const pre = this._activePre;
423
+ if (!pre) return;
424
+ this._hide();
425
+ if (pre.parentNode) pre.parentNode.removeChild(pre);
426
+ this.context.invoke('editor.afterCommand');
427
+ }
428
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Codeview.js - HTML source code view / WYSIWYG toggle
3
+ * Inspired by Summernote's Codeview module
4
+ */
5
+
6
+ import { createElement } from '../core/dom.js';
7
+ import { sanitiseHTML } from '../core/sanitise.js';
8
+
9
+ export class Codeview {
10
+ /**
11
+ * @param {import('../Context.js').Context} context
12
+ */
13
+ constructor(context) {
14
+ this.context = context;
15
+ this.options = context.options;
16
+ this._active = false;
17
+ /** @type {HTMLTextAreaElement|null} */
18
+ this._textarea = null;
19
+ this._disposers = [];
20
+ }
21
+
22
+ initialize() {
23
+ return this;
24
+ }
25
+
26
+ destroy() {
27
+ this._disposers.forEach((d) => d());
28
+ this._disposers = [];
29
+ if (this._textarea && this._textarea.parentNode) {
30
+ this._textarea.parentNode.removeChild(this._textarea);
31
+ }
32
+ this._textarea = null;
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Public API
37
+ // ---------------------------------------------------------------------------
38
+
39
+ toggle() {
40
+ if (this._active) {
41
+ this.deactivate();
42
+ } else {
43
+ this.activate();
44
+ }
45
+ }
46
+
47
+ isActive() {
48
+ return this._active;
49
+ }
50
+
51
+ activate() {
52
+ if (this._active) return;
53
+ const { editable } = this.context.layoutInfo;
54
+ const html = editable.innerHTML;
55
+
56
+ this._textarea = createElement('textarea', {
57
+ class: 'an-codeview',
58
+ spellcheck: 'false',
59
+ autocomplete: 'off',
60
+ autocorrect: 'off',
61
+ autocapitalize: 'off',
62
+ });
63
+ this._textarea.value = this._prettyPrint(html);
64
+
65
+ editable.style.display = 'none';
66
+ editable.parentNode.insertBefore(this._textarea, editable.nextSibling);
67
+
68
+ this._active = true;
69
+ this.context.invoke('toolbar.refresh');
70
+ }
71
+
72
+ deactivate() {
73
+ if (!this._active || !this._textarea) return;
74
+ const { editable } = this.context.layoutInfo;
75
+ // Sanitise the HTML typed in the textarea before applying (allow iframes for video embeds)
76
+ editable.innerHTML = sanitiseHTML(this._textarea.value, { allowIframes: true });
77
+ this._textarea.parentNode.removeChild(this._textarea);
78
+ this._textarea = null;
79
+ editable.style.display = '';
80
+ this._active = false;
81
+ this.context.invoke('toolbar.refresh');
82
+ this.context.invoke('editor.afterCommand');
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Helpers
87
+ // ---------------------------------------------------------------------------
88
+
89
+ /**
90
+ * Very simple HTML pretty-printer (indent nested tags).
91
+ * Inline elements (a, span, strong, em, etc.) are kept on one line to
92
+ * avoid injecting whitespace that would change rendered text.
93
+ * @param {string} html
94
+ * @returns {string}
95
+ */
96
+ _prettyPrint(html) {
97
+ // Tags whose content must NOT be split with newlines (breaks text rendering)
98
+ const INLINE_RE = /^<(a|abbr|b|bdo|br|button|cite|code|dfn|em|i|img|input|kbd|label|output|q|samp|select|small|span|strong|sub|sup|textarea|time|tt|u|var)([\s>/])/i;
99
+ let indent = 0;
100
+ return html
101
+ .replace(/>\s*</g, '>\n<')
102
+ .split('\n')
103
+ .map((line) => {
104
+ const stripped = line.trim();
105
+ if (!stripped) return '';
106
+ if (/^<\//.test(stripped)) indent = Math.max(0, indent - 1);
107
+ const out = ' '.repeat(indent) + stripped;
108
+ if (
109
+ /^<[^/!][^>]*[^/]>/.test(stripped) &&
110
+ !INLINE_RE.test(stripped) &&
111
+ !/^<(br|hr|img|input|link|meta)/.test(stripped)
112
+ ) {
113
+ indent++;
114
+ }
115
+ return out;
116
+ })
117
+ .filter(Boolean)
118
+ .join('\n');
119
+ }
120
+
121
+ }
122
+