suneditor 3.0.0-rc.4 → 3.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 (171) hide show
  1. package/README.md +4 -3
  2. package/dist/suneditor-contents.min.css +1 -1
  3. package/dist/suneditor.min.css +1 -1
  4. package/dist/suneditor.min.js +1 -1
  5. package/package.json +10 -6
  6. package/src/assets/design/color.css +14 -2
  7. package/src/assets/design/typography.css +5 -0
  8. package/src/assets/icons/defaultIcons.js +22 -4
  9. package/src/assets/suneditor-contents.css +1 -1
  10. package/src/assets/suneditor.css +312 -18
  11. package/src/core/config/eventManager.js +6 -9
  12. package/src/core/editor.js +1 -1
  13. package/src/core/event/actions/index.js +5 -0
  14. package/src/core/event/effects/keydown.registry.js +25 -0
  15. package/src/core/event/eventOrchestrator.js +69 -2
  16. package/src/core/event/handlers/handler_ww_mouse.js +1 -0
  17. package/src/core/event/rules/keydown.rule.backspace.js +9 -1
  18. package/src/core/kernel/coreKernel.js +4 -0
  19. package/src/core/kernel/store.js +2 -0
  20. package/src/core/logic/dom/char.js +11 -0
  21. package/src/core/logic/dom/format.js +22 -0
  22. package/src/core/logic/dom/html.js +126 -11
  23. package/src/core/logic/dom/nodeTransform.js +13 -0
  24. package/src/core/logic/dom/offset.js +100 -37
  25. package/src/core/logic/dom/selection.js +54 -22
  26. package/src/core/logic/panel/finder.js +982 -0
  27. package/src/core/logic/panel/menu.js +8 -6
  28. package/src/core/logic/panel/toolbar.js +112 -19
  29. package/src/core/logic/panel/viewer.js +214 -43
  30. package/src/core/logic/shell/_commandExecutor.js +7 -1
  31. package/src/core/logic/shell/commandDispatcher.js +1 -1
  32. package/src/core/logic/shell/component.js +5 -7
  33. package/src/core/logic/shell/history.js +24 -0
  34. package/src/core/logic/shell/shortcuts.js +3 -3
  35. package/src/core/logic/shell/ui.js +25 -26
  36. package/src/core/schema/frameContext.js +15 -1
  37. package/src/core/schema/options.js +180 -39
  38. package/src/core/section/constructor.js +61 -20
  39. package/src/core/section/documentType.js +2 -2
  40. package/src/events.js +12 -0
  41. package/src/helper/clipboard.js +1 -1
  42. package/src/helper/converter.js +15 -0
  43. package/src/helper/dom/domQuery.js +12 -0
  44. package/src/helper/dom/domUtils.js +26 -14
  45. package/src/helper/index.js +3 -0
  46. package/src/helper/markdown.js +876 -0
  47. package/src/interfaces/plugins.js +7 -5
  48. package/src/langs/ckb.js +9 -0
  49. package/src/langs/cs.js +9 -0
  50. package/src/langs/da.js +9 -0
  51. package/src/langs/de.js +9 -0
  52. package/src/langs/en.js +9 -0
  53. package/src/langs/es.js +9 -0
  54. package/src/langs/fa.js +9 -0
  55. package/src/langs/fr.js +9 -0
  56. package/src/langs/he.js +9 -0
  57. package/src/langs/hu.js +9 -0
  58. package/src/langs/it.js +9 -0
  59. package/src/langs/ja.js +9 -0
  60. package/src/langs/km.js +9 -0
  61. package/src/langs/ko.js +9 -0
  62. package/src/langs/lv.js +9 -0
  63. package/src/langs/nl.js +9 -0
  64. package/src/langs/pl.js +9 -0
  65. package/src/langs/pt_br.js +9 -0
  66. package/src/langs/ro.js +9 -0
  67. package/src/langs/ru.js +9 -0
  68. package/src/langs/se.js +9 -0
  69. package/src/langs/tr.js +9 -0
  70. package/src/langs/uk.js +9 -0
  71. package/src/langs/ur.js +9 -0
  72. package/src/langs/zh_cn.js +9 -0
  73. package/src/modules/contract/Browser.js +31 -1
  74. package/src/modules/contract/ColorPicker.js +6 -0
  75. package/src/modules/contract/Controller.js +77 -39
  76. package/src/modules/contract/Figure.js +57 -0
  77. package/src/modules/contract/Modal.js +6 -0
  78. package/src/modules/manager/ApiManager.js +53 -4
  79. package/src/modules/manager/FileManager.js +18 -1
  80. package/src/modules/ui/ModalAnchorEditor.js +35 -2
  81. package/src/modules/ui/SelectMenu.js +44 -12
  82. package/src/plugins/browser/fileBrowser.js +5 -2
  83. package/src/plugins/command/codeBlock.js +324 -0
  84. package/src/plugins/command/exportPDF.js +15 -3
  85. package/src/plugins/command/fileUpload.js +4 -1
  86. package/src/plugins/dropdown/backgroundColor.js +5 -1
  87. package/src/plugins/dropdown/blockStyle.js +8 -2
  88. package/src/plugins/dropdown/fontColor.js +5 -1
  89. package/src/plugins/dropdown/hr.js +6 -0
  90. package/src/plugins/dropdown/layout.js +4 -1
  91. package/src/plugins/dropdown/lineHeight.js +3 -0
  92. package/src/plugins/dropdown/paragraphStyle.js +5 -5
  93. package/src/plugins/dropdown/table/index.js +4 -1
  94. package/src/plugins/dropdown/table/render/table.html.js +1 -1
  95. package/src/plugins/dropdown/table/services/table.grid.js +16 -8
  96. package/src/plugins/dropdown/table/services/table.style.js +5 -9
  97. package/src/plugins/dropdown/template.js +3 -0
  98. package/src/plugins/dropdown/textStyle.js +5 -1
  99. package/src/plugins/field/mention.js +5 -1
  100. package/src/plugins/index.js +3 -0
  101. package/src/plugins/input/fontSize.js +10 -3
  102. package/src/plugins/modal/audio.js +7 -3
  103. package/src/plugins/modal/embed.js +23 -20
  104. package/src/plugins/modal/image/index.js +5 -1
  105. package/src/plugins/modal/math.js +7 -2
  106. package/src/plugins/modal/video/index.js +21 -4
  107. package/src/themes/cobalt.css +13 -4
  108. package/src/themes/cream.css +11 -2
  109. package/src/themes/dark.css +13 -4
  110. package/src/themes/midnight.css +13 -4
  111. package/src/typedef.js +4 -4
  112. package/types/assets/icons/defaultIcons.d.ts +12 -1
  113. package/types/assets/suneditor.css.d.ts +1 -1
  114. package/types/core/config/eventManager.d.ts +6 -8
  115. package/types/core/event/actions/index.d.ts +1 -0
  116. package/types/core/event/effects/keydown.registry.d.ts +2 -0
  117. package/types/core/event/eventOrchestrator.d.ts +2 -1
  118. package/types/core/kernel/coreKernel.d.ts +5 -0
  119. package/types/core/kernel/store.d.ts +5 -0
  120. package/types/core/logic/dom/char.d.ts +11 -0
  121. package/types/core/logic/dom/format.d.ts +22 -0
  122. package/types/core/logic/dom/html.d.ts +16 -0
  123. package/types/core/logic/dom/nodeTransform.d.ts +13 -0
  124. package/types/core/logic/dom/offset.d.ts +23 -2
  125. package/types/core/logic/dom/selection.d.ts +9 -3
  126. package/types/core/logic/panel/finder.d.ts +83 -0
  127. package/types/core/logic/panel/toolbar.d.ts +14 -1
  128. package/types/core/logic/panel/viewer.d.ts +22 -2
  129. package/types/core/logic/shell/shortcuts.d.ts +1 -1
  130. package/types/core/schema/frameContext.d.ts +22 -0
  131. package/types/core/schema/options.d.ts +362 -79
  132. package/types/events.d.ts +11 -0
  133. package/types/helper/converter.d.ts +15 -0
  134. package/types/helper/dom/domQuery.d.ts +12 -0
  135. package/types/helper/dom/domUtils.d.ts +23 -2
  136. package/types/helper/index.d.ts +5 -0
  137. package/types/helper/markdown.d.ts +27 -0
  138. package/types/interfaces/plugins.d.ts +7 -5
  139. package/types/langs/_Lang.d.ts +9 -0
  140. package/types/modules/contract/Browser.d.ts +36 -2
  141. package/types/modules/contract/ColorPicker.d.ts +6 -0
  142. package/types/modules/contract/Controller.d.ts +35 -1
  143. package/types/modules/contract/Figure.d.ts +57 -0
  144. package/types/modules/contract/Modal.d.ts +6 -0
  145. package/types/modules/manager/ApiManager.d.ts +26 -0
  146. package/types/modules/manager/FileManager.d.ts +17 -0
  147. package/types/modules/ui/ModalAnchorEditor.d.ts +41 -4
  148. package/types/modules/ui/SelectMenu.d.ts +40 -2
  149. package/types/plugins/browser/fileBrowser.d.ts +10 -4
  150. package/types/plugins/command/codeBlock.d.ts +53 -0
  151. package/types/plugins/command/fileUpload.d.ts +8 -2
  152. package/types/plugins/dropdown/backgroundColor.d.ts +10 -2
  153. package/types/plugins/dropdown/blockStyle.d.ts +14 -2
  154. package/types/plugins/dropdown/fontColor.d.ts +10 -2
  155. package/types/plugins/dropdown/hr.d.ts +12 -0
  156. package/types/plugins/dropdown/layout.d.ts +8 -2
  157. package/types/plugins/dropdown/lineHeight.d.ts +6 -0
  158. package/types/plugins/dropdown/paragraphStyle.d.ts +14 -3
  159. package/types/plugins/dropdown/table/index.d.ts +9 -3
  160. package/types/plugins/dropdown/template.d.ts +6 -0
  161. package/types/plugins/dropdown/textStyle.d.ts +10 -2
  162. package/types/plugins/field/mention.d.ts +10 -2
  163. package/types/plugins/index.d.ts +3 -0
  164. package/types/plugins/input/fontSize.d.ts +18 -4
  165. package/types/plugins/modal/audio.d.ts +14 -6
  166. package/types/plugins/modal/embed.d.ts +44 -38
  167. package/types/plugins/modal/image/index.d.ts +9 -1
  168. package/types/plugins/modal/link.d.ts +6 -2
  169. package/types/plugins/modal/math.d.ts +23 -5
  170. package/types/plugins/modal/video/index.d.ts +49 -9
  171. package/types/typedef.d.ts +5 -2
@@ -0,0 +1,876 @@
1
+ /**
2
+ * @fileoverview Markdown converter module
3
+ * - Supports GitHub Flavored Markdown (GFM) syntax
4
+ */
5
+
6
+ const VOID_ELEMENTS = /^(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/i;
7
+ const ZERO_WIDTH_RE = /^[\u200B\uFEFF]+$/;
8
+
9
+ /**
10
+ * @description Check if a string is empty or contains only zero-width characters
11
+ * @param {string} str
12
+ * @returns {boolean}
13
+ */
14
+ function isBlankLine(str) {
15
+ const trimmed = str.trim();
16
+ return trimmed === '' || ZERO_WIDTH_RE.test(trimmed);
17
+ }
18
+
19
+ /**
20
+ * @description Inline tag to markdown syntax mapping
21
+ */
22
+ const INLINE_WRAP_MAP = {
23
+ strong: '**',
24
+ b: '**',
25
+ em: '*',
26
+ i: '*',
27
+ del: '~~',
28
+ s: '~~',
29
+ mark: '==',
30
+ };
31
+
32
+ /**
33
+ * @description Process children of a JSON node into inline markdown
34
+ * @param {Array<Object>} children
35
+ * @returns {string}
36
+ */
37
+ function childrenToInline(children) {
38
+ if (!children || children.length === 0) return '';
39
+
40
+ const parts = [];
41
+ for (let i = 0; i < children.length; i++) {
42
+ const part = nodeToMarkdown(children[i], '', false);
43
+ if (part) {
44
+ // Add space between adjacent parts when both are non-empty and
45
+ // the previous part doesn't end with space and current doesn't start with space
46
+ if (parts.length > 0) {
47
+ const prev = parts[parts.length - 1];
48
+ if (prev && !prev.endsWith(' ') && !prev.endsWith('\n') && !part.startsWith(' ') && !part.startsWith('\n')) {
49
+ parts.push(' ');
50
+ }
51
+ }
52
+ parts.push(part);
53
+ }
54
+ }
55
+ return parts.join('');
56
+ }
57
+
58
+ /**
59
+ * @description Get text content from a JSON node recursively
60
+ * @param {Object} node
61
+ * @returns {string}
62
+ */
63
+ function getTextContent(node) {
64
+ if (!node) return '';
65
+ if (node.type === 'text') return node.content || '';
66
+ if (node.children) return node.children.map(getTextContent).join('');
67
+ return '';
68
+ }
69
+
70
+ /**
71
+ * @description Convert a JSON element node to its HTML string (for fallback)
72
+ * @param {Object} node
73
+ * @returns {string}
74
+ */
75
+ function nodeToHtmlFallback(node) {
76
+ if (!node) return '';
77
+ if (node.type === 'text') return node.content || '';
78
+
79
+ const { tag, attributes = {}, children = [] } = node;
80
+ const attrStr = Object.entries(attributes)
81
+ .map(([k, v]) => `${k}="${v}"`)
82
+ .join(' ');
83
+ const openTag = attrStr ? `<${tag} ${attrStr}>` : `<${tag}>`;
84
+
85
+ if (VOID_ELEMENTS.test(tag)) return openTag;
86
+
87
+ const inner = children.map(nodeToHtmlFallback).join('');
88
+ return `${openTag}${inner}</${tag}>`;
89
+ }
90
+
91
+ /**
92
+ * @description Indent each line of a block string
93
+ * @param {string} str
94
+ * @param {string} indent
95
+ * @returns {string}
96
+ */
97
+ function indentBlock(str, indent) {
98
+ if (!str) return '';
99
+ return (
100
+ str
101
+ .split('\n')
102
+ .map((line) => (line ? indent + line : ''))
103
+ .join('\n') + '\n'
104
+ );
105
+ }
106
+
107
+ /**
108
+ * @description Convert a list JSON node (ul/ol) to markdown
109
+ * @param {Object} node
110
+ * @param {string} indent Current indentation
111
+ * @param {boolean} isOrdered
112
+ * @returns {string}
113
+ */
114
+ function listToMarkdown(node, indent, isOrdered) {
115
+ const children = node.children || [];
116
+ let result = '';
117
+ let index = 1;
118
+
119
+ for (const child of children) {
120
+ if (child.type !== 'element' || child.tag !== 'li') continue;
121
+
122
+ const prefix = isOrdered ? `${index}. ` : '- ';
123
+ const liChildren = child.children || [];
124
+ let inlineContent = '';
125
+ let subBlocks = '';
126
+ const blockIndent = indent + ' '.repeat(prefix.length);
127
+
128
+ // GFM task list: check for checkbox input
129
+ let taskPrefix = '';
130
+ const firstChild = liChildren[0];
131
+ if (firstChild && firstChild.type === 'element' && firstChild.tag === 'input' && firstChild.attributes?.type === 'checkbox') {
132
+ taskPrefix = firstChild.attributes.checked !== undefined ? '[x] ' : '[ ] ';
133
+ }
134
+
135
+ for (let ci = 0; ci < liChildren.length; ci++) {
136
+ const liChild = liChildren[ci];
137
+ // Skip checkbox input already handled
138
+ if (ci === 0 && taskPrefix) continue;
139
+
140
+ if (liChild.type !== 'element') {
141
+ inlineContent += nodeToMarkdown(liChild, '', false);
142
+ continue;
143
+ }
144
+
145
+ const tag = liChild.tag;
146
+ if (tag === 'ul' || tag === 'ol') {
147
+ subBlocks += listToMarkdown(liChild, blockIndent, tag === 'ol');
148
+ } else if (tag === 'table') {
149
+ subBlocks += indentBlock(tableToMarkdown(liChild), blockIndent);
150
+ } else if (tag === 'figure') {
151
+ // figure wraps table/image in editor (e.g. <figure><table>...</table></figure>)
152
+ const tableChild = (liChild.children || []).find((c) => c.type === 'element' && c.tag === 'table');
153
+ if (tableChild) {
154
+ subBlocks += indentBlock(tableToMarkdown(tableChild), blockIndent);
155
+ } else {
156
+ subBlocks += indentBlock(nodeToMarkdown(liChild, '', true), blockIndent);
157
+ }
158
+ } else if (tag === 'blockquote' || tag === 'pre') {
159
+ subBlocks += indentBlock(nodeToMarkdown(liChild, '', true), blockIndent);
160
+ } else {
161
+ inlineContent += nodeToMarkdown(liChild, '', false);
162
+ }
163
+ }
164
+
165
+ result += `${indent}${prefix}${taskPrefix}${inlineContent.trim()}\n`;
166
+ if (subBlocks) result += subBlocks;
167
+ index++;
168
+ }
169
+
170
+ return result;
171
+ }
172
+
173
+ /**
174
+ * @description Convert a table JSON node to markdown pipe table
175
+ * @param {Object} node
176
+ * @returns {string}
177
+ */
178
+ function tableToMarkdown(node) {
179
+ const rawRows = [];
180
+ let hasHeader = false;
181
+
182
+ // Collect raw rows from thead/tbody/tfoot or direct tr children
183
+ function collectRows(parent, isHead) {
184
+ for (const child of parent.children || []) {
185
+ if (child.tag === 'thead') {
186
+ collectRows(child, true);
187
+ } else if (child.tag === 'tbody' || child.tag === 'tfoot') {
188
+ collectRows(child, false);
189
+ } else if (child.tag === 'tr') {
190
+ const cells = [];
191
+ for (const cell of child.children || []) {
192
+ if (cell.tag === 'td' || cell.tag === 'th') {
193
+ const content = childrenToInline(cell.children).trim();
194
+ const colspan = parseInt(cell.attributes?.colspan, 10) || 1;
195
+ const rowspan = parseInt(cell.attributes?.rowspan, 10) || 1;
196
+ cells.push({ content, colspan, rowspan });
197
+ }
198
+ }
199
+ const isHeaderRow = isHead || (!hasHeader && child.children?.some((c) => c.tag === 'th'));
200
+ if (isHeaderRow) hasHeader = true;
201
+ rawRows.push({ cells, isHeader: isHeaderRow });
202
+ }
203
+ }
204
+ }
205
+
206
+ collectRows(node, false);
207
+ if (rawRows.length === 0) return '';
208
+
209
+ // Move header rows to front
210
+ const headerRows = rawRows.filter((r) => r.isHeader);
211
+ const bodyRows = rawRows.filter((r) => !r.isHeader);
212
+ const orderedRows = [...headerRows, ...bodyRows];
213
+
214
+ // Determine column count by expanding colspan
215
+ let colCount = 0;
216
+ for (const row of orderedRows) {
217
+ let count = 0;
218
+ for (const cell of row.cells) {
219
+ count += cell.colspan;
220
+ }
221
+ if (count > colCount) colCount = count;
222
+ }
223
+
224
+ // Build a 2D grid, expanding colspan and rowspan
225
+ // null = unoccupied, string = occupied (content or empty string for spanned cells)
226
+ const grid = Array.from({ length: orderedRows.length }, () => new Array(colCount).fill(null));
227
+
228
+ for (let r = 0; r < orderedRows.length; r++) {
229
+ let col = 0;
230
+ for (const cell of orderedRows[r].cells) {
231
+ // Skip columns already occupied by rowspan from above
232
+ while (col < colCount && grid[r][col] !== null) col++;
233
+ if (col >= colCount) break;
234
+
235
+ // Fill colspan and rowspan cells
236
+ for (let rs = 0; rs < cell.rowspan && r + rs < orderedRows.length; rs++) {
237
+ for (let cs = 0; cs < cell.colspan && col + cs < colCount; cs++) {
238
+ // Only put content in the first cell; spanned cells get empty string
239
+ grid[r + rs][col + cs] = rs === 0 && cs === 0 ? cell.content : '';
240
+ }
241
+ }
242
+ col += cell.colspan;
243
+ }
244
+ // Fill any remaining null cells with empty string
245
+ for (let c = 0; c < colCount; c++) {
246
+ if (grid[r][c] === null) grid[r][c] = '';
247
+ }
248
+ }
249
+
250
+ let result = '';
251
+ const hasHeaderRow = headerRows.length > 0;
252
+
253
+ if (hasHeaderRow) {
254
+ // Use actual header row
255
+ result += '| ' + grid[0].join(' | ') + ' |\n';
256
+ result += '| ' + grid[0].map(() => '---').join(' | ') + ' |\n';
257
+ for (let r = 1; r < grid.length; r++) {
258
+ result += '| ' + grid[r].join(' | ') + ' |\n';
259
+ }
260
+ } else {
261
+ // No header: insert empty header row
262
+ result += '| ' + new Array(colCount).fill(' ').join(' | ') + ' |\n';
263
+ result += '| ' + new Array(colCount).fill('---').join(' | ') + ' |\n';
264
+ for (let r = 0; r < grid.length; r++) {
265
+ result += '| ' + grid[r].join(' | ') + ' |\n';
266
+ }
267
+ }
268
+
269
+ return result;
270
+ }
271
+
272
+ /**
273
+ * @description Convert a single JSON node to markdown
274
+ * @param {Object} node JSON node from htmlToJson
275
+ * @param {string} indent Current indentation for block-level
276
+ * @param {boolean} isBlock Whether we're in block context
277
+ * @returns {string}
278
+ */
279
+ function nodeToMarkdown(node, indent, isBlock) {
280
+ if (!node) return '';
281
+
282
+ // Text node - strip zero-width characters (editor artifacts)
283
+ if (node.type === 'text') {
284
+ return (node.content || '').replace(/[\u200B\uFEFF]/g, '');
285
+ }
286
+
287
+ if (node.type !== 'element') return '';
288
+
289
+ const { tag, attributes = {}, children = [] } = node;
290
+
291
+ // Body (root node from htmlToJson)
292
+ if (tag === 'body') {
293
+ return children.map((c) => nodeToMarkdown(c, '', true)).join('');
294
+ }
295
+
296
+ // Headings
297
+ const headingMatch = /^h([1-6])$/.exec(tag);
298
+ if (headingMatch) {
299
+ const level = parseInt(headingMatch[1], 10);
300
+ const prefix = '#'.repeat(level);
301
+ return `${prefix} ${childrenToInline(children).trim()}\n\n`;
302
+ }
303
+
304
+ if (tag === 'p') {
305
+ const content = childrenToInline(children);
306
+ // Empty paragraph
307
+ if (!content.trim()) return '\n';
308
+ return `${content.trim()}\n\n`;
309
+ }
310
+
311
+ if (tag === 'br') {
312
+ return '\n';
313
+ }
314
+
315
+ if (tag === 'hr') {
316
+ return '---\n\n';
317
+ }
318
+
319
+ // Inline formatting
320
+ const wrapSyntax = INLINE_WRAP_MAP[tag];
321
+ if (wrapSyntax) {
322
+ const content = childrenToInline(children);
323
+ return `${wrapSyntax}${content}${wrapSyntax}`;
324
+ }
325
+
326
+ // Inline code
327
+ if (tag === 'code') {
328
+ const content = getTextContent({ children });
329
+ // Use double backticks if content contains backtick
330
+ if (content.includes('`')) {
331
+ return '`` ' + content + ' ``';
332
+ }
333
+ return '`' + content + '`';
334
+ }
335
+
336
+ // Keyboard input
337
+ if (tag === 'kbd') {
338
+ const content = getTextContent({ children });
339
+ return `<kbd>${content}</kbd>`;
340
+ }
341
+
342
+ // Subscript / Superscript
343
+ if (tag === 'sub') {
344
+ const content = childrenToInline(children);
345
+ return `<sub>${content}</sub>`;
346
+ }
347
+ if (tag === 'sup') {
348
+ const content = childrenToInline(children);
349
+ return `<sup>${content}</sup>`;
350
+ }
351
+
352
+ if (tag === 'a') {
353
+ const href = attributes.href || '';
354
+ const text = childrenToInline(children);
355
+ const title = attributes.title;
356
+ if (title) return `[${text}](${href} "${title}")`;
357
+ return `[${text}](${href})`;
358
+ }
359
+
360
+ if (tag === 'img') {
361
+ const src = attributes.src || '';
362
+ const alt = attributes.alt || '';
363
+ return `![${alt}](${src})`;
364
+ }
365
+
366
+ if (tag === 'blockquote') {
367
+ const inner = children.map((c) => nodeToMarkdown(c, '', true)).join('');
368
+ // Prefix each line with >
369
+ const lines = inner.replace(/\n$/, '').split('\n');
370
+ return lines.map((line) => `> ${line}`).join('\n') + '\n\n';
371
+ }
372
+
373
+ // Pre / code block
374
+ if (tag === 'pre') {
375
+ let lang = '';
376
+ let codeContent = '';
377
+
378
+ // Check if pre contains a code element
379
+ const codeChild = children.find((c) => c.type === 'element' && c.tag === 'code');
380
+ if (codeChild) {
381
+ lang = (codeChild.attributes?.class || '').replace(/^language-/, '');
382
+ codeContent = getTextContent(codeChild);
383
+ } else {
384
+ // Also check pre's own class for language- (editor internal format)
385
+ lang = (node.attributes?.class || '').match(/language-(\S+)/)?.[1] || '';
386
+ codeContent = getTextContent({ children });
387
+ }
388
+
389
+ // Replace <br> represented as newlines
390
+ return '```' + lang + '\n' + codeContent + '\n```\n\n';
391
+ }
392
+
393
+ // Details/Summary (GFM)
394
+ if (tag === 'details') {
395
+ return nodeToHtmlFallback(node) + '\n\n';
396
+ }
397
+ if (tag === 'summary') {
398
+ return nodeToHtmlFallback(node);
399
+ }
400
+
401
+ // Lists
402
+ if (tag === 'ul') {
403
+ return listToMarkdown(node, indent, false) + '\n';
404
+ }
405
+ if (tag === 'ol') {
406
+ return listToMarkdown(node, indent, true) + '\n';
407
+ }
408
+
409
+ if (tag === 'table') {
410
+ return tableToMarkdown(node) + '\n';
411
+ }
412
+
413
+ if (tag === 'dl') {
414
+ let result = '';
415
+ for (const child of children) {
416
+ if (child.type !== 'element') continue;
417
+ if (child.tag === 'dt') {
418
+ result += childrenToInline(child.children).trim() + '\n';
419
+ } else if (child.tag === 'dd') {
420
+ result += ': ' + childrenToInline(child.children).trim() + '\n';
421
+ }
422
+ }
423
+ return result + '\n';
424
+ }
425
+ if (tag === 'dt' || tag === 'dd') {
426
+ return childrenToInline(children).trim();
427
+ }
428
+
429
+ // Div - check for component containers, otherwise process children
430
+ if (tag === 'div') {
431
+ // Component containers - process inner content
432
+ if (attributes.class && /se-component/.test(attributes.class)) {
433
+ return children.map((c) => nodeToMarkdown(c, indent, true)).join('');
434
+ }
435
+ const content = childrenToInline(children);
436
+ if (isBlock) return content + '\n\n';
437
+ return content;
438
+ }
439
+
440
+ // Span - pass through as inline (may contain styles)
441
+ if (tag === 'span') {
442
+ // Check if it has meaningful attributes that need HTML fallback
443
+ if (attributes.style || attributes.class) {
444
+ return nodeToHtmlFallback(node);
445
+ }
446
+ return childrenToInline(children);
447
+ }
448
+
449
+ // Figure - process children (usually contains img or other media)
450
+ if (tag === 'figure') {
451
+ return children.map((c) => nodeToMarkdown(c, indent, true)).join('');
452
+ }
453
+ if (tag === 'figcaption') {
454
+ const content = childrenToInline(children).trim();
455
+ return content ? content + '\n\n' : '';
456
+ }
457
+
458
+ // Video / Audio / Iframe - fallback to HTML
459
+ if (tag === 'video' || tag === 'audio' || tag === 'iframe') {
460
+ return nodeToHtmlFallback(node) + '\n\n';
461
+ }
462
+
463
+ // Abbreviation
464
+ if (tag === 'abbr') {
465
+ return nodeToHtmlFallback(node);
466
+ }
467
+
468
+ // HTML fallback
469
+ return nodeToHtmlFallback(node);
470
+ }
471
+
472
+ /**
473
+ * @description Converts a JSON tree (from htmlToJson) to a Markdown string.
474
+ * @param {Object} jsonNode JSON node from htmlToJson
475
+ * @returns {string} Markdown string
476
+ * @example
477
+ * const json = htmlToJson('<p><strong>Hello</strong> World</p>');
478
+ * const md = jsonToMarkdown(json);
479
+ * // '**Hello** World\n\n'
480
+ */
481
+ export function jsonToMarkdown(jsonNode) {
482
+ if (!jsonNode) return '';
483
+ const result = nodeToMarkdown(jsonNode, '', true);
484
+ // Trim trailing whitespace but keep final newline
485
+ return result.replace(/\n{3,}/g, '\n\n').trim() + '\n';
486
+ }
487
+
488
+ // ========================================================================
489
+ // Markdown -> HTML Parser
490
+ // ========================================================================
491
+
492
+ /**
493
+ * @description Parse inline markdown syntax to HTML
494
+ * @param {string} text
495
+ * @returns {string} HTML string
496
+ */
497
+ function parseInline(text) {
498
+ if (!text) return '';
499
+
500
+ const placeholders = [];
501
+ function hold(html) {
502
+ placeholders.push(html);
503
+ return '\uFFFC' + (placeholders.length - 1) + '\uFFFC';
504
+ }
505
+
506
+ // 1. Escape sequences: \* \_ \~ \` \[ \] \( \) \# \! \\ \| \{ \} \< \> \+ \-
507
+ text = text.replace(/\\([\\*_~`[\]()#!|{}<>+-])/g, (_, ch) => hold(ch));
508
+
509
+ // 2. Inline code — extract to placeholder so inner syntax is not parsed
510
+ text = text.replace(/``\s(.+?)\s``/g, (_, c) => hold('<code>' + c + '</code>'));
511
+ text = text.replace(/`([^`]+)`/g, (_, c) => hold('<code>' + c + '</code>'));
512
+
513
+ // 3. Images: ![alt](src "title") or ![alt](src)
514
+ text = text.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, (_, alt, src, title) => {
515
+ return hold(title ? `<img src="${src}" alt="${alt}" title="${title}">` : `<img src="${src}" alt="${alt}">`);
516
+ });
517
+
518
+ // 4. Links: [text](url "title") or [text](url)
519
+ text = text.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, (_, t, url, title) => {
520
+ return hold(title ? `<a href="${url}" title="${title}">${t}</a>` : `<a href="${url}">${t}</a>`);
521
+ });
522
+
523
+ // 5. Autolinks: <https://url> or <email@example.com>
524
+ text = text.replace(/<(https?:\/\/[^>]+)>/g, (_, url) => hold(`<a href="${url}">${url}</a>`));
525
+ text = text.replace(/<([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})>/g, (_, email) => hold(`<a href="mailto:${email}">${email}</a>`));
526
+
527
+ // 6. GFM autolink: bare URLs (only when not already inside a tag attribute)
528
+ text = text.replace(/(?<![="'\w/])(https?:\/\/[^\s<>\])"]+)/g, (_, url) => hold(`<a href="${url}">${url}</a>`));
529
+
530
+ // 7. Strikethrough first (before single tilde subscript)
531
+ text = text.replace(/~~(.+?)~~/g, '<del>$1</del>');
532
+
533
+ // 8. Bold+italic: ***text*** or ___text___
534
+ text = text.replace(/\*{3}(.+?)\*{3}/g, '<strong><em>$1</em></strong>');
535
+ text = text.replace(/_{3}(.+?)_{3}/g, '<strong><em>$1</em></strong>');
536
+
537
+ // 9. Bold: **text** or __text__
538
+ text = text.replace(/\*{2}(.+?)\*{2}/g, '<strong>$1</strong>');
539
+ text = text.replace(/_{2}(.+?)_{2}/g, '<strong>$1</strong>');
540
+
541
+ // 10. Italic: *text* or _text_
542
+ text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
543
+ text = text.replace(/(?<![a-zA-Z0-9])_(.+?)_(?![a-zA-Z0-9])/g, '<em>$1</em>');
544
+
545
+ // 11. GFM highlight: ==text==
546
+ text = text.replace(/==(.+?)==/g, '<mark>$1</mark>');
547
+
548
+ // 12. Superscript: ^text^
549
+ text = text.replace(/\^([^\s^]+)\^/g, '<sup>$1</sup>');
550
+
551
+ // 13. Subscript: ~text~ (single tilde only, after strikethrough is already consumed)
552
+ text = text.replace(/(?<!~)~([^\s~]+)~(?!~)/g, '<sub>$1</sub>');
553
+
554
+ // Restore all placeholders
555
+ text = text.replace(/\uFFFC(\d+)\uFFFC/g, (_, idx) => placeholders[parseInt(idx, 10)]);
556
+
557
+ return text;
558
+ }
559
+
560
+ /**
561
+ * @description Parses a Markdown string into an HTML string.
562
+ * - HTML tags in the markdown are passed through as-is (for fallback elements).
563
+ * @param {string} md Markdown string
564
+ * @param {string} [defaultLine='p'] Default block element tag
565
+ * @returns {string} HTML string
566
+ * @example
567
+ * markdownToHtml('# Hello\n\n**bold** text');
568
+ * // '<h1>Hello</h1><p><strong>bold</strong> text</p>'
569
+ */
570
+ export function markdownToHtml(md, defaultLine) {
571
+ if (!md) return '';
572
+ if (!defaultLine) defaultLine = 'p';
573
+
574
+ const lines = md.split('\n');
575
+ const output = [];
576
+ let i = 0;
577
+
578
+ while (i < lines.length) {
579
+ const line = lines[i];
580
+
581
+ // Empty line (or zero-width-only line) - skip
582
+ if (isBlankLine(line)) {
583
+ i++;
584
+ continue;
585
+ }
586
+
587
+ // Code fence: ``` or ~~~
588
+ const fenceMatch = /^(`{3,}|~{3,})(\w*)/.exec(line);
589
+ if (fenceMatch) {
590
+ const fenceChar = fenceMatch[1].charAt(0);
591
+ const fenceLen = fenceMatch[1].length;
592
+ const lang = fenceMatch[2];
593
+ const codeLines = [];
594
+ i++;
595
+ // Close fence must use same char and at least same length
596
+ const closeRe = new RegExp('^' + (fenceChar === '`' ? '`' : '~') + '{' + fenceLen + ',}\\s*$');
597
+ while (i < lines.length && !closeRe.test(lines[i])) {
598
+ codeLines.push(lines[i]);
599
+ i++;
600
+ }
601
+ i++; // skip closing fence
602
+ const langAttr = lang ? ` class="language-${lang}"` : '';
603
+ output.push(`<pre><code${langAttr}>${escapeHtml(codeLines.join('\n'))}</code></pre>`);
604
+ continue;
605
+ }
606
+
607
+ // Heading: # ~ ######
608
+ const headingMatch = /^(#{1,6})\s+(.+)/.exec(line);
609
+ if (headingMatch) {
610
+ const level = headingMatch[1].length;
611
+ output.push(`<h${level}>${parseInline(headingMatch[2].trim())}</h${level}>`);
612
+ i++;
613
+ continue;
614
+ }
615
+
616
+ // Horizontal rule: ---, ***, ___
617
+ if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(line)) {
618
+ output.push('<hr>');
619
+ i++;
620
+ continue;
621
+ }
622
+
623
+ // Blockquote
624
+ if (/^>\s?/.test(line)) {
625
+ const quoteLines = [];
626
+ while (i < lines.length && /^>\s?/.test(lines[i])) {
627
+ quoteLines.push(lines[i].replace(/^>\s?/, ''));
628
+ i++;
629
+ }
630
+ const innerHtml = markdownToHtml(quoteLines.join('\n'), defaultLine);
631
+ output.push(`<blockquote>${innerHtml}</blockquote>`);
632
+ continue;
633
+ }
634
+
635
+ // Table
636
+ if (/^\|.*\|/.test(line) && i + 1 < lines.length && /^\|[\s:]*-{3,}[\s:]*/.test(lines[i + 1])) {
637
+ const tableLines = [];
638
+ while (i < lines.length && /^\|.*\|/.test(lines[i])) {
639
+ tableLines.push(lines[i]);
640
+ i++;
641
+ }
642
+ output.push(parseTable(tableLines));
643
+ continue;
644
+ }
645
+
646
+ // Unordered list: - item, * item, + item
647
+ if (/^(\s*)([-*+])\s+/.test(line)) {
648
+ const result = parseList(lines, i, false);
649
+ output.push(result.html);
650
+ i = result.index;
651
+ continue;
652
+ }
653
+
654
+ // Ordered list: 1. item
655
+ if (/^(\s*)\d+\.\s+/.test(line)) {
656
+ const result = parseList(lines, i, true);
657
+ output.push(result.html);
658
+ i = result.index;
659
+ continue;
660
+ }
661
+
662
+ // HTML tag pass-through (starts with <)
663
+ if (/^\s*<[a-zA-Z]/.test(line)) {
664
+ // Collect all lines until we find the closing or a blank line
665
+ let htmlBlock = line;
666
+ i++;
667
+ // If it's a self-closing or known void element, just pass it
668
+ if (VOID_ELEMENTS.test(line.match(/<(\w+)/)?.[1] || '')) {
669
+ output.push(htmlBlock);
670
+ continue;
671
+ }
672
+ // For block-level HTML, collect until blank line
673
+ while (i < lines.length && !isBlankLine(lines[i])) {
674
+ htmlBlock += '\n' + lines[i];
675
+ i++;
676
+ }
677
+ output.push(htmlBlock);
678
+ continue;
679
+ }
680
+
681
+ // Paragraph (default)
682
+ const paraLines = [];
683
+ while (i < lines.length && !isBlankLine(lines[i]) && !/^(#{1,6}\s|`{3,}|~{3,}|>|\||(\s*)([-*+]|\d+\.)\s|(\*{3,}|-{3,}|_{3,})\s*$)/.test(lines[i])) {
684
+ paraLines.push(lines[i]);
685
+ i++;
686
+ }
687
+ if (paraLines.length > 0) {
688
+ const content = parseInline(paraLines.join('\n'));
689
+ output.push(`<${defaultLine}>${content}</${defaultLine}>`);
690
+ } else {
691
+ // Line matched a block-break pattern but no handler caught it (e.g., "|" without table separator)
692
+ output.push(`<${defaultLine}>${parseInline(lines[i])}</${defaultLine}>`);
693
+ i++;
694
+ }
695
+ }
696
+
697
+ return output.join('');
698
+ }
699
+
700
+ /**
701
+ * @description Parse markdown table lines into HTML table
702
+ * @param {string[]} tableLines
703
+ * @returns {string}
704
+ */
705
+ function parseTable(tableLines) {
706
+ if (tableLines.length < 2) return '';
707
+
708
+ const parseRow = (line) =>
709
+ line
710
+ .replace(/^\|/, '')
711
+ .replace(/\|$/, '')
712
+ .split('|')
713
+ .map((c) => c.trim());
714
+
715
+ const headers = parseRow(tableLines[0]);
716
+ // Parse separator line for alignment
717
+ const separators = parseRow(tableLines[1]);
718
+ const aligns = separators.map((sep) => {
719
+ const trimmed = sep.trim();
720
+ if (/^:-+:$/.test(trimmed)) return 'center';
721
+ if (/^-+:$/.test(trimmed)) return 'right';
722
+ if (/^:-+$/.test(trimmed)) return 'left';
723
+ return '';
724
+ });
725
+
726
+ const rows = tableLines.slice(2).map(parseRow);
727
+ const colCount = headers.length;
728
+
729
+ // Check if header row is empty (all cells blank)
730
+ const isEmptyHeader = headers.every((h) => h === '');
731
+
732
+ let html = '<table>';
733
+
734
+ const alignAttr = (idx) => {
735
+ const a = aligns[idx];
736
+ return a ? ` style="text-align: ${a};"` : '';
737
+ };
738
+
739
+ if (!isEmptyHeader) {
740
+ html += '<thead><tr>';
741
+ for (let c = 0; c < colCount; c++) {
742
+ html += `<th${alignAttr(c)}>${parseInline(headers[c])}</th>`;
743
+ }
744
+ html += '</tr></thead>';
745
+ }
746
+
747
+ if (rows.length > 0 || isEmptyHeader) {
748
+ html += '<tbody>';
749
+ for (const row of rows) {
750
+ html += '<tr>';
751
+ for (let c = 0; c < colCount; c++) {
752
+ html += `<td${alignAttr(c)}>${parseInline(row[c] || '')}</td>`;
753
+ }
754
+ html += '</tr>';
755
+ }
756
+ html += '</tbody>';
757
+ }
758
+
759
+ html += '</table>';
760
+ return html;
761
+ }
762
+
763
+ /**
764
+ * @description Parse markdown list into HTML
765
+ * @param {string[]} lines
766
+ * @param {number} startIndex
767
+ * @param {boolean} ordered
768
+ * @returns {{ html: string, index: number }}
769
+ */
770
+ function parseList(lines, startIndex, ordered) {
771
+ const tag = ordered ? 'ol' : 'ul';
772
+ const itemPattern = ordered ? /^(\s*)\d+\.\s+(.*)/ : /^(\s*)([-*+])\s+(.*)/;
773
+ let html = `<${tag}>`;
774
+ let i = startIndex;
775
+ const baseIndent = (lines[i].match(/^(\s*)/)[1] || '').length;
776
+
777
+ while (i < lines.length) {
778
+ const line = lines[i];
779
+ if (isBlankLine(line)) {
780
+ i++;
781
+ continue;
782
+ }
783
+
784
+ const currentIndent = (line.match(/^(\s*)/)[1] || '').length;
785
+
786
+ // If less indented than base, we're done
787
+ if (currentIndent < baseIndent) break;
788
+
789
+ // If at base level, check if it matches our list pattern
790
+ if (currentIndent === baseIndent) {
791
+ const match = itemPattern.exec(line);
792
+ if (!match) break;
793
+
794
+ const content = ordered ? line.replace(/^\s*\d+\.\s+/, '') : line.replace(/^\s*[-*+]\s+/, '');
795
+ i++;
796
+
797
+ // GFM task list checkbox
798
+ let isTask = false;
799
+ let taskChecked = false;
800
+ let displayContent = content;
801
+ const taskMatch = /^\[([ xX])\]\s*(.*)/.exec(content);
802
+ if (taskMatch) {
803
+ isTask = true;
804
+ taskChecked = taskMatch[1] !== ' ';
805
+ displayContent = taskMatch[2];
806
+ }
807
+
808
+ // Collect nested blocks (sublists, tables, etc.) that are more indented
809
+ let nestedHtml = '';
810
+ while (i < lines.length) {
811
+ const nextLine = lines[i];
812
+ if (isBlankLine(nextLine)) {
813
+ i++;
814
+ continue;
815
+ }
816
+ const nextIndent = (nextLine.match(/^(\s*)/)[1] || '').length;
817
+ if (nextIndent <= baseIndent) break;
818
+
819
+ // Nested list
820
+ if (/^\s*[-*+]\s+/.test(nextLine) || /^\s*\d+\.\s+/.test(nextLine)) {
821
+ const nested = parseList(lines, i, /^\s*\d+\.\s+/.test(nextLine));
822
+ nestedHtml += nested.html;
823
+ i = nested.index;
824
+ continue;
825
+ }
826
+
827
+ // Nested table (indented pipe table)
828
+ const trimmedLine = nextLine.trimStart();
829
+ if (/^\|.*\|/.test(trimmedLine) && i + 1 < lines.length && /^\s*\|[\s:]*-{3,}/.test(lines[i + 1])) {
830
+ const tableLines = [];
831
+ while (i < lines.length) {
832
+ const tl = lines[i];
833
+ if (isBlankLine(tl) || (tl.match(/^(\s*)/)[1] || '').length <= baseIndent) break;
834
+ if (!/^\s*\|/.test(tl)) break;
835
+ tableLines.push(tl.trimStart());
836
+ i++;
837
+ }
838
+ nestedHtml += parseTable(tableLines);
839
+ continue;
840
+ }
841
+
842
+ // Other indented content — treat as continuation
843
+ break;
844
+ }
845
+
846
+ if (isTask) {
847
+ const checkbox = taskChecked ? '<input type="checkbox" checked disabled> ' : '<input type="checkbox" disabled> ';
848
+ html += `<li class="task-list-item">${checkbox}${parseInline(displayContent)}${nestedHtml}</li>`;
849
+ } else {
850
+ html += `<li>${parseInline(content)}${nestedHtml}</li>`;
851
+ }
852
+ } else {
853
+ // More indented - this shouldn't happen if nested blocks are handled above
854
+ break;
855
+ }
856
+ }
857
+
858
+ html += `</${tag}>`;
859
+ return { html, index: i };
860
+ }
861
+
862
+ /**
863
+ * @description Escape HTML special characters
864
+ * @param {string} str
865
+ * @returns {string}
866
+ */
867
+ function escapeHtml(str) {
868
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
869
+ }
870
+
871
+ const markdown = {
872
+ jsonToMarkdown,
873
+ markdownToHtml,
874
+ };
875
+
876
+ export default markdown;