quasar-ui-danx 0.4.99 → 0.5.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.
Files changed (90) hide show
  1. package/dist/danx.es.js +17884 -12732
  2. package/dist/danx.es.js.map +1 -1
  3. package/dist/danx.umd.js +192 -118
  4. package/dist/danx.umd.js.map +1 -1
  5. package/dist/style.css +1 -1
  6. package/package.json +11 -2
  7. package/scripts/publish.sh +76 -0
  8. package/src/components/Utility/Code/CodeViewer.vue +31 -14
  9. package/src/components/Utility/Code/CodeViewerCollapsed.vue +2 -0
  10. package/src/components/Utility/Code/CodeViewerFooter.vue +1 -1
  11. package/src/components/Utility/Code/LanguageBadge.vue +278 -5
  12. package/src/components/Utility/Code/MarkdownContent.vue +160 -6
  13. package/src/components/Utility/Code/index.ts +3 -0
  14. package/src/components/Utility/Markdown/ContextMenu.vue +314 -0
  15. package/src/components/Utility/Markdown/HotkeyHelpPopover.vue +259 -0
  16. package/src/components/Utility/Markdown/LineTypeMenu.vue +226 -0
  17. package/src/components/Utility/Markdown/LinkPopover.vue +331 -0
  18. package/src/components/Utility/Markdown/MarkdownEditor.vue +228 -0
  19. package/src/components/Utility/Markdown/MarkdownEditorContent.vue +235 -0
  20. package/src/components/Utility/Markdown/MarkdownEditorFooter.vue +50 -0
  21. package/src/components/Utility/Markdown/TablePopover.vue +420 -0
  22. package/src/components/Utility/Markdown/index.ts +11 -0
  23. package/src/components/Utility/Markdown/types.ts +27 -0
  24. package/src/components/Utility/index.ts +1 -0
  25. package/src/composables/index.ts +1 -0
  26. package/src/composables/markdown/features/useBlockquotes.spec.ts +428 -0
  27. package/src/composables/markdown/features/useBlockquotes.ts +248 -0
  28. package/src/composables/markdown/features/useCodeBlockManager.ts +369 -0
  29. package/src/composables/markdown/features/useCodeBlocks.spec.ts +779 -0
  30. package/src/composables/markdown/features/useCodeBlocks.ts +774 -0
  31. package/src/composables/markdown/features/useContextMenu.ts +444 -0
  32. package/src/composables/markdown/features/useFocusTracking.ts +116 -0
  33. package/src/composables/markdown/features/useHeadings.spec.ts +834 -0
  34. package/src/composables/markdown/features/useHeadings.ts +290 -0
  35. package/src/composables/markdown/features/useInlineFormatting.spec.ts +705 -0
  36. package/src/composables/markdown/features/useInlineFormatting.ts +402 -0
  37. package/src/composables/markdown/features/useLineTypeMenu.ts +285 -0
  38. package/src/composables/markdown/features/useLinks.spec.ts +369 -0
  39. package/src/composables/markdown/features/useLinks.ts +374 -0
  40. package/src/composables/markdown/features/useLists.spec.ts +834 -0
  41. package/src/composables/markdown/features/useLists.ts +747 -0
  42. package/src/composables/markdown/features/usePopoverManager.ts +181 -0
  43. package/src/composables/markdown/features/useTables.spec.ts +1601 -0
  44. package/src/composables/markdown/features/useTables.ts +1107 -0
  45. package/src/composables/markdown/index.ts +16 -0
  46. package/src/composables/markdown/useMarkdownEditor.spec.ts +332 -0
  47. package/src/composables/markdown/useMarkdownEditor.ts +1068 -0
  48. package/src/composables/markdown/useMarkdownHotkeys.spec.ts +791 -0
  49. package/src/composables/markdown/useMarkdownHotkeys.ts +266 -0
  50. package/src/composables/markdown/useMarkdownSelection.ts +219 -0
  51. package/src/composables/markdown/useMarkdownSync.ts +549 -0
  52. package/src/composables/useCodeViewerEditor.spec.ts +655 -0
  53. package/src/composables/useCodeViewerEditor.ts +174 -20
  54. package/src/helpers/formats/index.ts +1 -1
  55. package/src/helpers/formats/markdown/escapeHtml.ts +15 -0
  56. package/src/helpers/formats/markdown/escapeSequences.ts +60 -0
  57. package/src/helpers/formats/markdown/htmlToMarkdown/convertHeadings.ts +41 -0
  58. package/src/helpers/formats/markdown/htmlToMarkdown/index.spec.ts +489 -0
  59. package/src/helpers/formats/markdown/htmlToMarkdown/index.ts +412 -0
  60. package/src/helpers/formats/markdown/index.ts +92 -0
  61. package/src/helpers/formats/markdown/linePatterns.spec.ts +495 -0
  62. package/src/helpers/formats/markdown/linePatterns.ts +172 -0
  63. package/src/helpers/formats/markdown/parseInline.ts +124 -0
  64. package/src/helpers/formats/markdown/render/index.ts +92 -0
  65. package/src/helpers/formats/markdown/render/renderFootnotes.ts +30 -0
  66. package/src/helpers/formats/markdown/render/renderList.ts +69 -0
  67. package/src/helpers/formats/markdown/render/renderTable.ts +38 -0
  68. package/src/helpers/formats/markdown/state.ts +58 -0
  69. package/src/helpers/formats/markdown/tokenize/extractDefinitions.ts +39 -0
  70. package/src/helpers/formats/markdown/tokenize/index.ts +139 -0
  71. package/src/helpers/formats/markdown/tokenize/parseBlockquote.ts +34 -0
  72. package/src/helpers/formats/markdown/tokenize/parseCodeBlock.ts +85 -0
  73. package/src/helpers/formats/markdown/tokenize/parseDefinitionList.ts +88 -0
  74. package/src/helpers/formats/markdown/tokenize/parseHeading.ts +65 -0
  75. package/src/helpers/formats/markdown/tokenize/parseHorizontalRule.ts +22 -0
  76. package/src/helpers/formats/markdown/tokenize/parseList.ts +119 -0
  77. package/src/helpers/formats/markdown/tokenize/parseParagraph.ts +59 -0
  78. package/src/helpers/formats/markdown/tokenize/parseTable.ts +70 -0
  79. package/src/helpers/formats/markdown/tokenize/parseTaskList.ts +47 -0
  80. package/src/helpers/formats/markdown/tokenize/utils.ts +25 -0
  81. package/src/helpers/formats/markdown/types.ts +63 -0
  82. package/src/styles/danx.scss +1 -0
  83. package/src/styles/themes/danx/markdown.scss +96 -0
  84. package/src/test/helpers/editorTestUtils.spec.ts +296 -0
  85. package/src/test/helpers/editorTestUtils.ts +253 -0
  86. package/src/test/helpers/index.ts +1 -0
  87. package/src/test/setup.test.ts +12 -0
  88. package/src/test/setup.ts +12 -0
  89. package/vitest.config.ts +19 -0
  90. package/src/helpers/formats/renderMarkdown.ts +0 -338
@@ -0,0 +1,774 @@
1
+ import { reactive, Ref } from "vue";
2
+ import { UseMarkdownSelectionReturn } from "../useMarkdownSelection";
3
+ import { detectCodeFenceStart } from "../../../helpers/formats/markdown/linePatterns";
4
+
5
+ /**
6
+ * Represents a code block's state
7
+ */
8
+ export interface CodeBlockState {
9
+ id: string;
10
+ content: string;
11
+ language: string;
12
+ }
13
+
14
+ /**
15
+ * Options for useCodeBlocks composable
16
+ */
17
+ export interface UseCodeBlocksOptions {
18
+ contentRef: Ref<HTMLElement | null>;
19
+ selection: UseMarkdownSelectionReturn;
20
+ onContentChange: () => void;
21
+ }
22
+
23
+ /**
24
+ * Return type for useCodeBlocks composable
25
+ */
26
+ export interface UseCodeBlocksReturn {
27
+ /** Reactive map of all code blocks by ID */
28
+ codeBlocks: Map<string, CodeBlockState>;
29
+ /** Toggle code block on current block (Ctrl+Shift+K) */
30
+ toggleCodeBlock: () => void;
31
+ /** Check for code fence pattern (```) and convert if matched */
32
+ checkAndConvertCodeBlockPattern: () => boolean;
33
+ /** Check if cursor is inside a code block */
34
+ isInCodeBlock: () => boolean;
35
+ /** Get current code block's language */
36
+ getCurrentCodeBlockLanguage: () => string | null;
37
+ /** Set language for current code block */
38
+ setCodeBlockLanguage: (language: string) => void;
39
+ /** Handle Enter key in code block - returns true if handled */
40
+ handleCodeBlockEnter: () => boolean;
41
+ /** Get all code blocks */
42
+ getCodeBlocks: () => Map<string, CodeBlockState>;
43
+ /** Update content for a specific code block */
44
+ updateCodeBlockContent: (id: string, content: string) => void;
45
+ /** Update language for a specific code block */
46
+ updateCodeBlockLanguage: (id: string, language: string) => void;
47
+ /** Remove a code block by ID */
48
+ removeCodeBlock: (id: string) => void;
49
+ /** Get code block state by ID */
50
+ getCodeBlockById: (id: string) => CodeBlockState | undefined;
51
+ /** Get current code block ID (if cursor is in one) */
52
+ getCurrentCodeBlockId: () => string | null;
53
+ /** Handle code block mounted event - focuses if pending */
54
+ handleCodeBlockMounted: (id: string, wrapper: HTMLElement) => void;
55
+ /** Register a code block in state (for initial markdown parsing) */
56
+ registerCodeBlock: (id: string, content: string, language: string) => void;
57
+ }
58
+
59
+ /**
60
+ * Generate a unique ID for code blocks
61
+ */
62
+ function generateCodeBlockId(): string {
63
+ return `cb-${crypto.randomUUID()}`;
64
+ }
65
+
66
+ /**
67
+ * Check if an element is a block type that can be converted to a code block
68
+ * Includes paragraphs, divs, and headings (H1-H6)
69
+ */
70
+ function isConvertibleBlock(element: Element): boolean {
71
+ const tag = element.tagName;
72
+ return tag === "P" || tag === "DIV" || /^H[1-6]$/.test(tag);
73
+ }
74
+
75
+ /**
76
+ * Get the block-level parent element containing the cursor
77
+ */
78
+ function getTargetBlock(contentRef: Ref<HTMLElement | null>, selection: UseMarkdownSelectionReturn): Element | null {
79
+ const currentBlock = selection.getCurrentBlock();
80
+ if (!currentBlock) return null;
81
+
82
+ // For paragraphs, divs, headings, and code block wrappers, return directly
83
+ if (isConvertibleBlock(currentBlock) || currentBlock.tagName === "PRE" || currentBlock.hasAttribute("data-code-block-id")) {
84
+ return currentBlock;
85
+ }
86
+
87
+ // For list items, return the LI
88
+ if (currentBlock.tagName === "LI") {
89
+ return currentBlock;
90
+ }
91
+
92
+ // Walk up to find a convertible block, code block wrapper, or PRE
93
+ if (!contentRef.value) return null;
94
+
95
+ let current: Element | null = currentBlock;
96
+ while (current && current.parentElement !== contentRef.value) {
97
+ if (isConvertibleBlock(current) || current.tagName === "PRE" || current.tagName === "LI" || current.hasAttribute("data-code-block-id")) {
98
+ return current;
99
+ }
100
+ current = current.parentElement;
101
+ }
102
+
103
+ // Check if this direct child is a convertible block, PRE, or code block wrapper
104
+ if (current && (isConvertibleBlock(current) || current.tagName === "PRE" || current.hasAttribute("data-code-block-id"))) {
105
+ return current;
106
+ }
107
+
108
+ return null;
109
+ }
110
+
111
+ /**
112
+ * Get the code block wrapper element containing the cursor (if in a code block)
113
+ */
114
+ function getCodeBlockWrapper(selection: UseMarkdownSelectionReturn): HTMLElement | null {
115
+ const currentBlock = selection.getCurrentBlock();
116
+ if (!currentBlock) return null;
117
+
118
+ // Walk up to find code block wrapper
119
+ let current: Element | null = currentBlock;
120
+ while (current) {
121
+ if (current.hasAttribute("data-code-block-id")) {
122
+ return current as HTMLElement;
123
+ }
124
+ // Legacy support: check for PRE without wrapper
125
+ if (current.tagName === "PRE" && !current.closest("[data-code-block-id]")) {
126
+ return null; // Legacy PRE, not wrapped
127
+ }
128
+ current = current.parentElement;
129
+ }
130
+
131
+ return null;
132
+ }
133
+
134
+ /**
135
+ * Get the PRE element containing the cursor (if in a code block)
136
+ * Supports both legacy PRE and new wrapper structure
137
+ */
138
+ function getCodeBlockElement(selection: UseMarkdownSelectionReturn): HTMLPreElement | null {
139
+ const currentBlock = selection.getCurrentBlock();
140
+ if (!currentBlock) return null;
141
+
142
+ // Walk up to find PRE
143
+ let current: Element | null = currentBlock;
144
+ while (current) {
145
+ if (current.tagName === "PRE") {
146
+ return current as HTMLPreElement;
147
+ }
148
+ current = current.parentElement;
149
+ }
150
+
151
+ return null;
152
+ }
153
+
154
+ /**
155
+ * Position cursor at end of element
156
+ */
157
+ function positionCursorAtEnd(element: Element): void {
158
+ const sel = window.getSelection();
159
+ if (!sel) return;
160
+
161
+ const range = document.createRange();
162
+
163
+ // Find last text node
164
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
165
+ let lastTextNode: Text | null = null;
166
+ let node: Text | null;
167
+ while ((node = walker.nextNode() as Text | null)) {
168
+ lastTextNode = node;
169
+ }
170
+
171
+ if (lastTextNode) {
172
+ range.setStart(lastTextNode, lastTextNode.length);
173
+ range.collapse(true);
174
+ } else {
175
+ range.selectNodeContents(element);
176
+ range.collapse(false);
177
+ }
178
+
179
+ sel.removeAllRanges();
180
+ sel.addRange(range);
181
+ }
182
+
183
+ /**
184
+ * Zero-width space character used as cursor anchor in empty elements.
185
+ * This is necessary because contenteditable doesn't position the cursor
186
+ * correctly in empty elements - subsequent typing ends up as sibling nodes.
187
+ */
188
+ export const CURSOR_ANCHOR = "\u200B";
189
+
190
+ /**
191
+ * Position cursor at start of element.
192
+ * If the element contains a zero-width space cursor anchor, positions after it
193
+ * so typing replaces/follows the anchor rather than creating sibling nodes.
194
+ */
195
+ function positionCursorAtStart(element: Element): void {
196
+ const sel = window.getSelection();
197
+ if (!sel) return;
198
+
199
+ const range = document.createRange();
200
+
201
+ // Find first text node
202
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
203
+ const firstTextNode = walker.nextNode() as Text | null;
204
+
205
+ if (firstTextNode) {
206
+ // If there's a cursor anchor (zero-width space), position after it
207
+ // so typing goes into the element rather than creating siblings
208
+ if (firstTextNode.textContent === CURSOR_ANCHOR) {
209
+ range.setStart(firstTextNode, firstTextNode.length);
210
+ } else {
211
+ range.setStart(firstTextNode, 0);
212
+ }
213
+ range.collapse(true);
214
+ } else {
215
+ range.selectNodeContents(element);
216
+ range.collapse(true);
217
+ }
218
+
219
+ sel.removeAllRanges();
220
+ sel.addRange(range);
221
+ }
222
+
223
+ /**
224
+ * Create a code block wrapper with non-editable island structure.
225
+ * Returns the wrapper div and the code block ID.
226
+ */
227
+ function createCodeBlockWrapper(content: string, language: string): { wrapper: HTMLDivElement; id: string } {
228
+ const id = generateCodeBlockId();
229
+
230
+ const wrapper = document.createElement("div");
231
+ wrapper.className = "code-block-wrapper";
232
+ wrapper.setAttribute("contenteditable", "false");
233
+ wrapper.setAttribute("data-code-block-id", id);
234
+
235
+ // Create mount point for CodeViewer
236
+ const mountPoint = document.createElement("div");
237
+ mountPoint.className = "code-viewer-mount-point";
238
+
239
+ // Store initial content and language as data attributes for the manager to read
240
+ mountPoint.setAttribute("data-content", content);
241
+ mountPoint.setAttribute("data-language", language);
242
+
243
+ wrapper.appendChild(mountPoint);
244
+
245
+ return { wrapper, id };
246
+ }
247
+
248
+ /**
249
+ * Convert a code block wrapper back to a paragraph
250
+ */
251
+ function convertCodeBlockToParagraph(wrapper: HTMLElement, codeBlocks: Map<string, CodeBlockState>): HTMLParagraphElement {
252
+ const p = document.createElement("p");
253
+ const id = wrapper.getAttribute("data-code-block-id");
254
+
255
+ // Get content from state
256
+ const state = id ? codeBlocks.get(id) : null;
257
+ p.textContent = state?.content || "";
258
+
259
+ // Remove from state
260
+ if (id) {
261
+ codeBlocks.delete(id);
262
+ }
263
+
264
+ // Replace wrapper with paragraph
265
+ wrapper.parentNode?.replaceChild(p, wrapper);
266
+
267
+ return p;
268
+ }
269
+
270
+ /**
271
+ * Convert legacy PRE/CODE to paragraph (backwards compatibility)
272
+ */
273
+ function convertLegacyCodeBlockToParagraph(pre: HTMLPreElement): HTMLParagraphElement {
274
+ const p = document.createElement("p");
275
+
276
+ // Get text content from code element or directly from pre
277
+ const codeElement = pre.querySelector("code");
278
+ p.textContent = codeElement?.textContent || pre.textContent || "";
279
+
280
+ // Replace pre with paragraph
281
+ pre.parentNode?.replaceChild(p, pre);
282
+
283
+ return p;
284
+ }
285
+
286
+ /**
287
+ * Composable for code block operations in markdown editor
288
+ */
289
+ export function useCodeBlocks(options: UseCodeBlocksOptions): UseCodeBlocksReturn {
290
+ const { contentRef, selection, onContentChange } = options;
291
+
292
+ // Reactive map to track all code blocks
293
+ const codeBlocks = reactive(new Map<string, CodeBlockState>());
294
+
295
+ // Track code blocks that should be focused when mounted
296
+ const pendingFocusIds = new Set<string>();
297
+
298
+ /**
299
+ * Focus the editable pre element inside a code block wrapper
300
+ */
301
+ function focusCodeBlockEditor(wrapper: HTMLElement): void {
302
+ // Find the CodeViewer's contenteditable pre element
303
+ const codeViewerPre = wrapper.querySelector("pre[contenteditable=\"true\"]") as HTMLElement | null;
304
+ if (codeViewerPre) {
305
+ // Focus the pre element
306
+ codeViewerPre.focus();
307
+
308
+ // Position cursor at start
309
+ const range = document.createRange();
310
+ range.selectNodeContents(codeViewerPre);
311
+ range.collapse(true); // Collapse to start
312
+ const sel = window.getSelection();
313
+ sel?.removeAllRanges();
314
+ sel?.addRange(range);
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Handle code block mounted event from useCodeBlockManager.
320
+ * If this code block was pending focus, focus it now.
321
+ */
322
+ function handleCodeBlockMounted(id: string, wrapper: HTMLElement): void {
323
+ if (pendingFocusIds.has(id)) {
324
+ pendingFocusIds.delete(id);
325
+ focusCodeBlockEditor(wrapper);
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Register a code block in state.
331
+ * Used during initial markdown parsing to register code blocks that are
332
+ * converted from <pre><code> to wrapper structure.
333
+ */
334
+ function registerCodeBlock(id: string, content: string, language: string): void {
335
+ codeBlocks.set(id, {
336
+ id,
337
+ content,
338
+ language
339
+ });
340
+ }
341
+
342
+ /**
343
+ * Get all code blocks
344
+ */
345
+ function getCodeBlocks(): Map<string, CodeBlockState> {
346
+ return codeBlocks;
347
+ }
348
+
349
+ /**
350
+ * Get code block state by ID
351
+ */
352
+ function getCodeBlockById(id: string): CodeBlockState | undefined {
353
+ return codeBlocks.get(id);
354
+ }
355
+
356
+ /**
357
+ * Update content for a specific code block
358
+ */
359
+ function updateCodeBlockContent(id: string, content: string): void {
360
+ const state = codeBlocks.get(id);
361
+ if (state) {
362
+ state.content = content;
363
+ onContentChange();
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Update language for a specific code block
369
+ */
370
+ function updateCodeBlockLanguage(id: string, language: string): void {
371
+ const state = codeBlocks.get(id);
372
+ if (state) {
373
+ state.language = language;
374
+ onContentChange();
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Remove a code block by ID
380
+ */
381
+ function removeCodeBlock(id: string): void {
382
+ codeBlocks.delete(id);
383
+ // Also remove from DOM if present
384
+ if (contentRef.value) {
385
+ const wrapper = contentRef.value.querySelector(`[data-code-block-id="${id}"]`);
386
+ if (wrapper) {
387
+ wrapper.remove();
388
+ }
389
+ }
390
+ onContentChange();
391
+ }
392
+
393
+ /**
394
+ * Get current code block ID (if cursor is in one)
395
+ */
396
+ function getCurrentCodeBlockId(): string | null {
397
+ const wrapper = getCodeBlockWrapper(selection);
398
+ return wrapper?.getAttribute("data-code-block-id") || null;
399
+ }
400
+
401
+ /**
402
+ * Check if cursor is inside a code block
403
+ */
404
+ function isInCodeBlock(): boolean {
405
+ // Check for new wrapper structure first
406
+ if (getCodeBlockWrapper(selection)) {
407
+ return true;
408
+ }
409
+ // Fall back to legacy PRE detection
410
+ return getCodeBlockElement(selection) !== null;
411
+ }
412
+
413
+ /**
414
+ * Get current code block's language
415
+ */
416
+ function getCurrentCodeBlockLanguage(): string | null {
417
+ // Check new wrapper structure first
418
+ const wrapper = getCodeBlockWrapper(selection);
419
+ if (wrapper) {
420
+ const id = wrapper.getAttribute("data-code-block-id");
421
+ if (id) {
422
+ const state = codeBlocks.get(id);
423
+ return state?.language ?? "";
424
+ }
425
+ }
426
+
427
+ // Fall back to legacy PRE/CODE detection
428
+ const pre = getCodeBlockElement(selection);
429
+ if (!pre) return null;
430
+
431
+ const codeElement = pre.querySelector("code");
432
+ if (!codeElement) return null;
433
+
434
+ const match = codeElement.className.match(/language-(\w+)/);
435
+ return match ? match[1] : "";
436
+ }
437
+
438
+ /**
439
+ * Set language for current code block
440
+ */
441
+ function setCodeBlockLanguage(language: string): void {
442
+ // Check new wrapper structure first
443
+ const wrapper = getCodeBlockWrapper(selection);
444
+ if (wrapper) {
445
+ const id = wrapper.getAttribute("data-code-block-id");
446
+ if (id) {
447
+ updateCodeBlockLanguage(id, language);
448
+ }
449
+ return;
450
+ }
451
+
452
+ // Fall back to legacy PRE/CODE handling
453
+ const pre = getCodeBlockElement(selection);
454
+ if (!pre) return;
455
+
456
+ const codeElement = pre.querySelector("code");
457
+ if (!codeElement) return;
458
+
459
+ // Remove existing language classes
460
+ let newClassName = codeElement.className.replace(/language-\w+/g, "").trim();
461
+
462
+ if (language) {
463
+ newClassName = (newClassName + ` language-${language}`).trim();
464
+ }
465
+
466
+ // Only set className if it has content, otherwise remove the attribute entirely
467
+ if (newClassName) {
468
+ codeElement.className = newClassName;
469
+ } else {
470
+ codeElement.removeAttribute("class");
471
+ }
472
+
473
+ onContentChange();
474
+ }
475
+
476
+ /**
477
+ * Handle Enter key press when in a code block
478
+ * For the new wrapper structure, this is handled by CodeViewer internally.
479
+ * This function handles legacy PRE/CODE structures.
480
+ * @returns true if the Enter was handled, false to let browser handle it
481
+ */
482
+ function handleCodeBlockEnter(): boolean {
483
+ if (!contentRef.value) return false;
484
+
485
+ // New wrapper structure handles Enter internally via CodeViewer
486
+ const wrapper = getCodeBlockWrapper(selection);
487
+ if (wrapper) {
488
+ // Let CodeViewer handle it
489
+ return false;
490
+ }
491
+
492
+ // Check if we're in a legacy code block
493
+ const pre = getCodeBlockElement(selection);
494
+ if (!pre) return false;
495
+
496
+ const codeElement = pre.querySelector("code");
497
+ if (!codeElement) return false;
498
+
499
+ // Get cursor position information
500
+ const sel = window.getSelection();
501
+ if (!sel || sel.rangeCount === 0) return false;
502
+
503
+ const range = sel.getRangeAt(0);
504
+
505
+ // Get the current text content (strip cursor anchor zero-width spaces)
506
+ const content = (codeElement.textContent || "").replace(/\u200B/g, "");
507
+
508
+ // Check if cursor is at the end of the code content
509
+ const cursorAtEnd = isCursorAtEndOfCode(codeElement, range);
510
+
511
+ // Check if content ends with two newlines (user pressed Enter twice already at the end)
512
+ if (cursorAtEnd && content.endsWith("\n\n")) {
513
+ // Exit code block: remove the trailing newlines, create paragraph after
514
+ // Update the code content by removing the trailing newlines
515
+ const newContent = content.slice(0, -2);
516
+ codeElement.textContent = newContent || CURSOR_ANCHOR;
517
+
518
+ // Create new paragraph after the code block
519
+ const p = document.createElement("p");
520
+ // Use a <br> to make the empty paragraph editable
521
+ p.appendChild(document.createElement("br"));
522
+ pre.parentNode?.insertBefore(p, pre.nextSibling);
523
+
524
+ // Position cursor in the new paragraph
525
+ positionCursorAtStart(p);
526
+
527
+ onContentChange();
528
+ return true;
529
+ }
530
+
531
+ // Insert a newline at cursor position
532
+ // When inserting at the end, we need to add a cursor anchor (zero-width space)
533
+ // after the newline to make the trailing newline visible in contenteditable.
534
+ // Browsers collapse trailing whitespace, so the anchor gives the cursor something
535
+ // to position on. The anchor is stripped during HTML-to-markdown conversion.
536
+ if (cursorAtEnd) {
537
+ insertTextAtCursorWithAnchor("\n", codeElement);
538
+ } else {
539
+ insertTextAtCursor("\n");
540
+ }
541
+
542
+ onContentChange();
543
+ return true;
544
+ }
545
+
546
+ /**
547
+ * Insert text at the current cursor position
548
+ */
549
+ function insertTextAtCursor(text: string): void {
550
+ const sel = window.getSelection();
551
+ if (!sel || sel.rangeCount === 0) return;
552
+
553
+ const range = sel.getRangeAt(0);
554
+ range.deleteContents();
555
+
556
+ const textNode = document.createTextNode(text);
557
+ range.insertNode(textNode);
558
+
559
+ // Position cursor after the inserted text
560
+ range.setStartAfter(textNode);
561
+ range.setEndAfter(textNode);
562
+ sel.removeAllRanges();
563
+ sel.addRange(range);
564
+ }
565
+
566
+ /**
567
+ * Insert text at cursor position with a cursor anchor at the end.
568
+ * This is used when inserting newlines at the end of code blocks to ensure
569
+ * the trailing newline is visible (browsers collapse trailing whitespace).
570
+ *
571
+ * First removes any existing cursor anchors from the code element to avoid
572
+ * accumulating multiple anchors, then inserts the text followed by a new anchor.
573
+ */
574
+ function insertTextAtCursorWithAnchor(text: string, codeElement: Element): void {
575
+ const sel = window.getSelection();
576
+ if (!sel || sel.rangeCount === 0) return;
577
+
578
+ // First, remove any existing cursor anchors from the code element
579
+ // to avoid accumulating multiple anchors
580
+ const currentContent = codeElement.textContent || "";
581
+ const cleanContent = currentContent.replace(/\u200B/g, "");
582
+
583
+ // Get cursor position relative to clean content
584
+ const range = sel.getRangeAt(0);
585
+
586
+ // Calculate the offset in the clean content
587
+ // We need to count how many characters come before the cursor, excluding cursor anchors
588
+ let cursorOffset = 0;
589
+ const walker = document.createTreeWalker(codeElement, NodeFilter.SHOW_TEXT);
590
+ let node: Text | null;
591
+ while ((node = walker.nextNode() as Text | null)) {
592
+ if (node === range.startContainer) {
593
+ // Count characters up to cursor position, excluding cursor anchors
594
+ const textBeforeCursor = node.textContent?.slice(0, range.startOffset) || "";
595
+ cursorOffset += textBeforeCursor.replace(/\u200B/g, "").length;
596
+ break;
597
+ } else {
598
+ cursorOffset += (node.textContent || "").replace(/\u200B/g, "").length;
599
+ }
600
+ }
601
+
602
+ // Build new content: content before cursor + new text + cursor anchor
603
+ const newContent = cleanContent.slice(0, cursorOffset) + text + CURSOR_ANCHOR + cleanContent.slice(cursorOffset);
604
+
605
+ // Set the new content
606
+ codeElement.textContent = newContent;
607
+
608
+ // Position cursor after the inserted text (before the cursor anchor)
609
+ const newCursorOffset = cursorOffset + text.length;
610
+ const newTextNode = codeElement.firstChild as Text;
611
+ if (newTextNode) {
612
+ const newRange = document.createRange();
613
+ newRange.setStart(newTextNode, newCursorOffset);
614
+ newRange.collapse(true);
615
+ sel.removeAllRanges();
616
+ sel.addRange(newRange);
617
+ }
618
+ }
619
+
620
+ /**
621
+ * Check if cursor is at the end of a code element's content.
622
+ * Considers cursor anchors (zero-width spaces) as "not content" - if the only
623
+ * thing after the cursor is a cursor anchor, it's still considered at the end.
624
+ */
625
+ function isCursorAtEndOfCode(codeElement: Element, range: Range): boolean {
626
+ // Create a range from cursor to end of code element
627
+ const testRange = document.createRange();
628
+ testRange.setStart(range.endContainer, range.endOffset);
629
+
630
+ // Find the last text node or the element itself if empty
631
+ const lastChild = codeElement.lastChild;
632
+ if (lastChild) {
633
+ if (lastChild.nodeType === Node.TEXT_NODE) {
634
+ testRange.setEnd(lastChild, (lastChild as Text).length);
635
+ } else {
636
+ testRange.setEndAfter(lastChild);
637
+ }
638
+ } else {
639
+ testRange.setEndAfter(codeElement);
640
+ }
641
+
642
+ // Get the text after the cursor, stripping cursor anchors
643
+ const textAfterCursor = testRange.toString().replace(/\u200B/g, "");
644
+
645
+ // If no real content after cursor (ignoring cursor anchors), cursor is at end
646
+ return textAfterCursor === "";
647
+ }
648
+
649
+ /**
650
+ * Toggle code block on the current block
651
+ * - If paragraph/div/heading: convert to code block wrapper
652
+ * - If already in code block: convert back to paragraph
653
+ * - If in a list: convert list item to paragraph first, then to code block
654
+ */
655
+ function toggleCodeBlock(): void {
656
+ if (!contentRef.value) return;
657
+
658
+ // Check if already in new-style code block wrapper
659
+ const wrapper = getCodeBlockWrapper(selection);
660
+ if (wrapper) {
661
+ const p = convertCodeBlockToParagraph(wrapper, codeBlocks);
662
+ positionCursorAtEnd(p);
663
+ onContentChange();
664
+ return;
665
+ }
666
+
667
+ // Check if in legacy code block
668
+ const pre = getCodeBlockElement(selection);
669
+ if (pre) {
670
+ // Convert legacy code block to paragraph
671
+ const p = convertLegacyCodeBlockToParagraph(pre);
672
+ positionCursorAtEnd(p);
673
+ onContentChange();
674
+ return;
675
+ }
676
+
677
+ // Get the target block
678
+ const block = getTargetBlock(contentRef, selection);
679
+ if (!block) return;
680
+
681
+ // If in a list item, we can't directly convert to code block
682
+ // The caller (MarkdownEditor) should handle this by first converting to paragraph
683
+ if (block.tagName === "LI") {
684
+ // For now, just return - the menu handler will deal with this
685
+ return;
686
+ }
687
+
688
+ // Convert to code block wrapper
689
+ if (isConvertibleBlock(block)) {
690
+ const content = block.textContent || "";
691
+ const { wrapper, id } = createCodeBlockWrapper(content, "");
692
+
693
+ // Register in state
694
+ codeBlocks.set(id, {
695
+ id,
696
+ content,
697
+ language: ""
698
+ });
699
+
700
+ // Replace block with wrapper
701
+ block.parentNode?.replaceChild(wrapper, block);
702
+
703
+ // Mark this code block for focus when it mounts
704
+ pendingFocusIds.add(id);
705
+
706
+ onContentChange();
707
+ }
708
+ }
709
+
710
+ /**
711
+ * Check if the current block contains a code fence pattern (``` or ```language)
712
+ * and convert it to the appropriate code block if detected.
713
+ * Only converts paragraphs/divs/headings, not existing code blocks.
714
+ * @returns true if a pattern was detected and converted, false otherwise
715
+ */
716
+ function checkAndConvertCodeBlockPattern(): boolean {
717
+ if (!contentRef.value) return false;
718
+
719
+ const block = getTargetBlock(contentRef, selection);
720
+ if (!block) return false;
721
+
722
+ // Only convert paragraphs, divs, or headings - not existing code blocks or list items
723
+ if (!isConvertibleBlock(block)) return false;
724
+
725
+ // Get the text content of the block
726
+ const textContent = block.textContent || "";
727
+
728
+ // Check for code fence pattern
729
+ const pattern = detectCodeFenceStart(textContent);
730
+ if (!pattern) return false;
731
+
732
+ // Pattern detected - convert to code block wrapper
733
+ const language = pattern.language || "";
734
+
735
+ const { wrapper, id } = createCodeBlockWrapper("", language);
736
+
737
+ // Register in state
738
+ codeBlocks.set(id, {
739
+ id,
740
+ content: "",
741
+ language
742
+ });
743
+
744
+ // Replace block with wrapper
745
+ block.parentNode?.replaceChild(wrapper, block);
746
+
747
+ // Mark this code block for focus when it mounts
748
+ // The useCodeBlockManager will call handleCodeBlockMounted after mounting
749
+ pendingFocusIds.add(id);
750
+
751
+ // Notify of content change
752
+ onContentChange();
753
+
754
+ return true;
755
+ }
756
+
757
+ return {
758
+ codeBlocks,
759
+ toggleCodeBlock,
760
+ checkAndConvertCodeBlockPattern,
761
+ isInCodeBlock,
762
+ getCurrentCodeBlockLanguage,
763
+ setCodeBlockLanguage,
764
+ handleCodeBlockEnter,
765
+ getCodeBlocks,
766
+ updateCodeBlockContent,
767
+ updateCodeBlockLanguage,
768
+ removeCodeBlock,
769
+ getCodeBlockById,
770
+ getCurrentCodeBlockId,
771
+ handleCodeBlockMounted,
772
+ registerCodeBlock
773
+ };
774
+ }