quasar-ui-danx 0.5.0 → 0.5.2

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 (81) hide show
  1. package/.claude/settings.local.json +8 -0
  2. package/dist/danx.es.js +16119 -10641
  3. package/dist/danx.es.js.map +1 -1
  4. package/dist/danx.umd.js +202 -123
  5. package/dist/danx.umd.js.map +1 -1
  6. package/dist/style.css +1 -1
  7. package/package.json +8 -1
  8. package/src/components/Utility/Buttons/ActionButton.vue +15 -5
  9. package/src/components/Utility/Code/CodeViewer.vue +41 -16
  10. package/src/components/Utility/Code/CodeViewerCollapsed.vue +2 -0
  11. package/src/components/Utility/Code/CodeViewerFooter.vue +3 -1
  12. package/src/components/Utility/Code/LanguageBadge.vue +278 -5
  13. package/src/components/Utility/Code/MarkdownContent.vue +31 -163
  14. package/src/components/Utility/Code/index.ts +3 -0
  15. package/src/components/Utility/Markdown/ContextMenu.vue +314 -0
  16. package/src/components/Utility/Markdown/HotkeyHelpPopover.vue +259 -0
  17. package/src/components/Utility/Markdown/LineTypeMenu.vue +226 -0
  18. package/src/components/Utility/Markdown/LinkPopover.vue +331 -0
  19. package/src/components/Utility/Markdown/MarkdownEditor.vue +233 -0
  20. package/src/components/Utility/Markdown/MarkdownEditorContent.vue +296 -0
  21. package/src/components/Utility/Markdown/MarkdownEditorFooter.vue +50 -0
  22. package/src/components/Utility/Markdown/TablePopover.vue +420 -0
  23. package/src/components/Utility/Markdown/index.ts +11 -0
  24. package/src/components/Utility/Markdown/types.ts +27 -0
  25. package/src/components/Utility/Widgets/LabelPillWidget.vue +20 -0
  26. package/src/components/Utility/index.ts +1 -0
  27. package/src/composables/index.ts +1 -0
  28. package/src/composables/markdown/features/useBlockquotes.spec.ts +428 -0
  29. package/src/composables/markdown/features/useBlockquotes.ts +248 -0
  30. package/src/composables/markdown/features/useCodeBlockManager.ts +369 -0
  31. package/src/composables/markdown/features/useCodeBlocks.spec.ts +805 -0
  32. package/src/composables/markdown/features/useCodeBlocks.ts +774 -0
  33. package/src/composables/markdown/features/useContextMenu.ts +444 -0
  34. package/src/composables/markdown/features/useFocusTracking.ts +116 -0
  35. package/src/composables/markdown/features/useHeadings.spec.ts +834 -0
  36. package/src/composables/markdown/features/useHeadings.ts +290 -0
  37. package/src/composables/markdown/features/useInlineFormatting.spec.ts +705 -0
  38. package/src/composables/markdown/features/useInlineFormatting.ts +402 -0
  39. package/src/composables/markdown/features/useLineTypeMenu.ts +285 -0
  40. package/src/composables/markdown/features/useLinks.spec.ts +388 -0
  41. package/src/composables/markdown/features/useLinks.ts +374 -0
  42. package/src/composables/markdown/features/useLists.spec.ts +834 -0
  43. package/src/composables/markdown/features/useLists.ts +747 -0
  44. package/src/composables/markdown/features/usePopoverManager.ts +181 -0
  45. package/src/composables/markdown/features/useTables.spec.ts +1601 -0
  46. package/src/composables/markdown/features/useTables.ts +1107 -0
  47. package/src/composables/markdown/index.ts +16 -0
  48. package/src/composables/markdown/useMarkdownEditor.spec.ts +332 -0
  49. package/src/composables/markdown/useMarkdownEditor.ts +1077 -0
  50. package/src/composables/markdown/useMarkdownHotkeys.spec.ts +791 -0
  51. package/src/composables/markdown/useMarkdownHotkeys.ts +266 -0
  52. package/src/composables/markdown/useMarkdownSelection.ts +219 -0
  53. package/src/composables/markdown/useMarkdownSync.ts +549 -0
  54. package/src/composables/useCodeFormat.ts +17 -10
  55. package/src/composables/useCodeViewerEditor.spec.ts +655 -0
  56. package/src/composables/useCodeViewerEditor.ts +174 -20
  57. package/src/helpers/formats/highlightCSS.ts +236 -0
  58. package/src/helpers/formats/highlightHTML.ts +483 -0
  59. package/src/helpers/formats/highlightJavaScript.ts +346 -0
  60. package/src/helpers/formats/highlightSyntax.ts +15 -4
  61. package/src/helpers/formats/index.ts +3 -0
  62. package/src/helpers/formats/markdown/htmlToMarkdown/convertHeadings.ts +41 -0
  63. package/src/helpers/formats/markdown/htmlToMarkdown/index.spec.ts +489 -0
  64. package/src/helpers/formats/markdown/htmlToMarkdown/index.ts +425 -0
  65. package/src/helpers/formats/markdown/index.ts +7 -0
  66. package/src/helpers/formats/markdown/linePatterns.spec.ts +498 -0
  67. package/src/helpers/formats/markdown/linePatterns.ts +172 -0
  68. package/src/styles/danx.scss +3 -3
  69. package/src/styles/index.scss +5 -5
  70. package/src/styles/themes/danx/code.scss +257 -1
  71. package/src/styles/themes/danx/index.scss +10 -10
  72. package/src/styles/themes/danx/markdown.scss +59 -0
  73. package/src/test/helpers/editorTestUtils.spec.ts +296 -0
  74. package/src/test/helpers/editorTestUtils.ts +253 -0
  75. package/src/test/helpers/index.ts +1 -0
  76. package/src/test/highlighters.test.ts +153 -0
  77. package/src/test/setup.test.ts +12 -0
  78. package/src/test/setup.ts +12 -0
  79. package/src/types/widgets.d.ts +2 -2
  80. package/vite.config.js +5 -1
  81. package/vitest.config.ts +19 -0
@@ -0,0 +1,1077 @@
1
+ import { computed, nextTick, Ref, ref } from "vue";
2
+ import { HotkeyDefinition, useMarkdownHotkeys } from "./useMarkdownHotkeys";
3
+ import { useMarkdownSelection } from "./useMarkdownSelection";
4
+ import { useMarkdownSync } from "./useMarkdownSync";
5
+ import { useBlockquotes } from "./features/useBlockquotes";
6
+ import { useCodeBlocks } from "./features/useCodeBlocks";
7
+ import { useCodeBlockManager } from "./features/useCodeBlockManager";
8
+ import { useHeadings } from "./features/useHeadings";
9
+ import { useInlineFormatting } from "./features/useInlineFormatting";
10
+ import { ShowLinkPopoverOptions, useLinks } from "./features/useLinks";
11
+ import { useLists } from "./features/useLists";
12
+ import { ShowTablePopoverOptions, useTables } from "./features/useTables";
13
+
14
+ /**
15
+ * Options for useMarkdownEditor composable
16
+ */
17
+ export interface UseMarkdownEditorOptions {
18
+ contentRef: Ref<HTMLElement | null>;
19
+ initialValue: string;
20
+ onEmitValue: (markdown: string) => void;
21
+ /** Callback to show the link popover UI */
22
+ onShowLinkPopover?: (options: ShowLinkPopoverOptions) => void;
23
+ /** Callback to show the table popover UI */
24
+ onShowTablePopover?: (options: ShowTablePopoverOptions) => void;
25
+ }
26
+
27
+ /**
28
+ * Return type for useMarkdownEditor composable
29
+ */
30
+ export interface UseMarkdownEditorReturn {
31
+ // From sync
32
+ renderedHtml: Ref<string>;
33
+ isInternalUpdate: Ref<boolean>;
34
+
35
+ // State
36
+ isShowingHotkeyHelp: Ref<boolean>;
37
+ charCount: Ref<number>;
38
+
39
+ // Event handlers
40
+ onInput: () => void;
41
+ onKeyDown: (event: KeyboardEvent) => void;
42
+ onBlur: () => void;
43
+
44
+ // For external value updates
45
+ setMarkdown: (markdown: string) => void;
46
+
47
+ // Formatting actions
48
+ insertHorizontalRule: () => void;
49
+
50
+ // Hotkey help
51
+ showHotkeyHelp: () => void;
52
+ hideHotkeyHelp: () => void;
53
+ hotkeyDefinitions: Ref<HotkeyDefinition[]>;
54
+
55
+ // Feature access (for custom hotkey registration)
56
+ headings: ReturnType<typeof useHeadings>;
57
+ inlineFormatting: ReturnType<typeof useInlineFormatting>;
58
+ links: ReturnType<typeof useLinks>;
59
+ lists: ReturnType<typeof useLists>;
60
+ codeBlocks: ReturnType<typeof useCodeBlocks>;
61
+ codeBlockManager: ReturnType<typeof useCodeBlockManager>;
62
+ blockquotes: ReturnType<typeof useBlockquotes>;
63
+ tables: ReturnType<typeof useTables>;
64
+ }
65
+
66
+ /**
67
+ * Main orchestrator composable for markdown editor
68
+ * Composes selection, sync, hotkeys, and feature composables
69
+ */
70
+ export function useMarkdownEditor(options: UseMarkdownEditorOptions): UseMarkdownEditorReturn {
71
+ const { contentRef, initialValue, onEmitValue, onShowLinkPopover, onShowTablePopover } = options;
72
+
73
+ // State
74
+ const isShowingHotkeyHelp = ref(false);
75
+
76
+ // Initialize selection management
77
+ const selection = useMarkdownSelection(contentRef);
78
+
79
+ // Initialize code blocks feature first (sync needs access to getCodeBlockById)
80
+ // Note: onContentChange will be set after sync is created
81
+ let syncCallback: (() => void) | null = null;
82
+ const codeBlocks = useCodeBlocks({
83
+ contentRef,
84
+ selection,
85
+ onContentChange: () => {
86
+ if (syncCallback) syncCallback();
87
+ }
88
+ });
89
+
90
+ // Initialize sync with code block lookup for HTML-to-markdown conversion
91
+ // and registration for markdown-to-HTML conversion (initial render)
92
+ const sync = useMarkdownSync({
93
+ contentRef,
94
+ onEmitValue,
95
+ debounceMs: 300,
96
+ getCodeBlockById: codeBlocks.getCodeBlockById,
97
+ registerCodeBlock: codeBlocks.registerCodeBlock
98
+ });
99
+
100
+ // Now set the sync callback for code blocks
101
+ syncCallback = () => sync.debouncedSyncFromHtml();
102
+
103
+ // Initialize hotkeys
104
+ const hotkeys = useMarkdownHotkeys({
105
+ contentRef,
106
+ onShowHotkeyHelp: () => {
107
+ isShowingHotkeyHelp.value = true;
108
+ }
109
+ });
110
+
111
+ // Initialize headings feature
112
+ const headings = useHeadings({
113
+ contentRef,
114
+ selection,
115
+ onContentChange: () => {
116
+ sync.debouncedSyncFromHtml();
117
+ }
118
+ });
119
+
120
+ // Initialize inline formatting feature
121
+ const inlineFormatting = useInlineFormatting({
122
+ contentRef,
123
+ onContentChange: () => {
124
+ sync.debouncedSyncFromHtml();
125
+ }
126
+ });
127
+
128
+ // Initialize lists feature
129
+ const lists = useLists({
130
+ contentRef,
131
+ selection,
132
+ onContentChange: () => {
133
+ sync.debouncedSyncFromHtml();
134
+ }
135
+ });
136
+
137
+ // Initialize blockquotes feature
138
+ const blockquotes = useBlockquotes({
139
+ contentRef,
140
+ onContentChange: () => {
141
+ sync.debouncedSyncFromHtml();
142
+ }
143
+ });
144
+
145
+ // Initialize links feature
146
+ const links = useLinks({
147
+ contentRef,
148
+ onContentChange: () => {
149
+ sync.debouncedSyncFromHtml();
150
+ },
151
+ onShowLinkPopover
152
+ });
153
+
154
+ // Initialize tables feature
155
+ const tables = useTables({
156
+ contentRef,
157
+ onContentChange: () => {
158
+ sync.debouncedSyncFromHtml();
159
+ },
160
+ onShowTablePopover
161
+ });
162
+
163
+ /**
164
+ * Handle code block exit (double-Enter at end of code block)
165
+ * Creates a new paragraph after the code block and positions cursor there
166
+ */
167
+ function handleCodeBlockExit(id: string): void {
168
+ if (!contentRef.value) return;
169
+
170
+ const wrapper = contentRef.value.querySelector(`[data-code-block-id="${id}"]`);
171
+ if (!wrapper) return;
172
+
173
+ // Create new paragraph after the code block
174
+ const p = document.createElement("p");
175
+ p.appendChild(document.createElement("br"));
176
+ wrapper.parentNode?.insertBefore(p, wrapper.nextSibling);
177
+
178
+ // Position cursor in the new paragraph
179
+ nextTick(() => {
180
+ const range = document.createRange();
181
+ range.selectNodeContents(p);
182
+ range.collapse(true);
183
+ const sel = window.getSelection();
184
+ sel?.removeAllRanges();
185
+ sel?.addRange(range);
186
+ p.focus();
187
+ });
188
+
189
+ sync.debouncedSyncFromHtml();
190
+ }
191
+
192
+ /**
193
+ * Handle code block delete (Backspace/Delete on empty code block)
194
+ * Removes the code block and positions cursor in the previous or next element
195
+ */
196
+ function handleCodeBlockDelete(id: string): void {
197
+ if (!contentRef.value) return;
198
+
199
+ const wrapper = contentRef.value.querySelector(`[data-code-block-id="${id}"]`);
200
+ if (!wrapper) return;
201
+
202
+ // Find previous or next sibling to position cursor
203
+ const previousSibling = wrapper.previousElementSibling;
204
+ const nextSibling = wrapper.nextElementSibling;
205
+
206
+ // Remove the code block from state (this will trigger MutationObserver cleanup)
207
+ codeBlocks.codeBlocks.delete(id);
208
+
209
+ // Remove the wrapper from DOM (MutationObserver will handle unmounting)
210
+ wrapper.remove();
211
+
212
+ // Position cursor in the appropriate element
213
+ nextTick(() => {
214
+ let targetElement: Element | null = null;
215
+
216
+ if (previousSibling) {
217
+ targetElement = previousSibling;
218
+ } else if (nextSibling) {
219
+ targetElement = nextSibling;
220
+ } else {
221
+ // No siblings - create a new paragraph
222
+ const p = document.createElement("p");
223
+ p.appendChild(document.createElement("br"));
224
+ contentRef.value?.appendChild(p);
225
+ targetElement = p;
226
+ }
227
+
228
+ if (targetElement) {
229
+ const range = document.createRange();
230
+ range.selectNodeContents(targetElement);
231
+ range.collapse(previousSibling ? false : true); // End if previous, start if next
232
+ const sel = window.getSelection();
233
+ sel?.removeAllRanges();
234
+ sel?.addRange(range);
235
+ }
236
+ });
237
+
238
+ sync.debouncedSyncFromHtml();
239
+ }
240
+
241
+ // Initialize code block manager for mounting CodeViewer instances
242
+ const codeBlockManager = useCodeBlockManager({
243
+ contentRef,
244
+ codeBlocks: codeBlocks.codeBlocks,
245
+ updateCodeBlockContent: codeBlocks.updateCodeBlockContent,
246
+ updateCodeBlockLanguage: codeBlocks.updateCodeBlockLanguage,
247
+ onCodeBlockExit: handleCodeBlockExit,
248
+ onCodeBlockDelete: handleCodeBlockDelete,
249
+ onCodeBlockMounted: codeBlocks.handleCodeBlockMounted
250
+ });
251
+
252
+ // Register default hotkeys
253
+ registerDefaultHotkeys();
254
+
255
+ /**
256
+ * Set heading level, handling list items by converting to paragraph first.
257
+ * This wrapper ensures Ctrl+0-6 hotkeys work even when cursor is in a list.
258
+ */
259
+ function setHeadingLevelWithListHandling(level: 0 | 1 | 2 | 3 | 4 | 5 | 6): void {
260
+ // Check if currently in a list
261
+ const listType = lists.getCurrentListType();
262
+ if (listType) {
263
+ // Convert list item to paragraph first
264
+ lists.convertCurrentListItemToParagraph();
265
+ }
266
+
267
+ // Now apply heading level (skip if level is 0 and we just converted from list,
268
+ // because convertCurrentListItemToParagraph already creates a paragraph)
269
+ if (level > 0 || !listType) {
270
+ headings.setHeadingLevel(level);
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Increase heading level, handling list items by converting to paragraph first.
276
+ * This wrapper ensures Ctrl+> hotkey works even when cursor is in a list.
277
+ */
278
+ function increaseHeadingLevelWithListHandling(): void {
279
+ const listType = lists.getCurrentListType();
280
+ if (listType) {
281
+ // Convert list item to paragraph first, then apply H6 (since P -> H6 is first step)
282
+ lists.convertCurrentListItemToParagraph();
283
+ headings.setHeadingLevel(6);
284
+ } else {
285
+ headings.increaseHeadingLevel();
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Decrease heading level, handling list items by converting to paragraph first.
291
+ * This wrapper ensures Ctrl+< hotkey works even when cursor is in a list.
292
+ * For list items, just converts to paragraph (since that's the lowest level).
293
+ */
294
+ function decreaseHeadingLevelWithListHandling(): void {
295
+ const listType = lists.getCurrentListType();
296
+ if (listType) {
297
+ // Convert list item to paragraph (already at paragraph level after conversion)
298
+ lists.convertCurrentListItemToParagraph();
299
+ } else {
300
+ headings.decreaseHeadingLevel();
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Toggle code block, handling list items by converting to paragraph first.
306
+ * This wrapper ensures Ctrl+Shift+K hotkey works even when cursor is in a list.
307
+ */
308
+ function toggleCodeBlockWithListHandling(): void {
309
+ // If already in a code block, just toggle off
310
+ if (codeBlocks.isInCodeBlock()) {
311
+ codeBlocks.toggleCodeBlock();
312
+ return;
313
+ }
314
+
315
+ // Check if currently in a list
316
+ const listType = lists.getCurrentListType();
317
+ if (listType) {
318
+ // Convert list item to paragraph first
319
+ lists.convertCurrentListItemToParagraph();
320
+ }
321
+
322
+ // Now toggle to code block
323
+ codeBlocks.toggleCodeBlock();
324
+ }
325
+
326
+ // Character count - updated on each input since textContent is not reactive
327
+ const charCount = ref(0);
328
+
329
+ /**
330
+ * Update the character count from the current content
331
+ */
332
+ function updateCharCount(): void {
333
+ charCount.value = contentRef.value?.textContent?.length || 0;
334
+ }
335
+
336
+ // Reactive hotkey definitions for UI
337
+ const hotkeyDefinitions = computed(() => {
338
+ return hotkeys.getHotkeyDefinitions();
339
+ });
340
+
341
+ /**
342
+ * Register default hotkeys for all features
343
+ */
344
+ function registerDefaultHotkeys(): void {
345
+ // === Inline Formatting Hotkeys ===
346
+ hotkeys.registerHotkey({
347
+ key: "ctrl+b",
348
+ action: () => inlineFormatting.toggleBold(),
349
+ description: "Bold",
350
+ group: "formatting"
351
+ });
352
+
353
+ hotkeys.registerHotkey({
354
+ key: "ctrl+i",
355
+ action: () => inlineFormatting.toggleItalic(),
356
+ description: "Italic",
357
+ group: "formatting"
358
+ });
359
+
360
+ hotkeys.registerHotkey({
361
+ key: "ctrl+e",
362
+ action: () => inlineFormatting.toggleInlineCode(),
363
+ description: "Inline code",
364
+ group: "formatting"
365
+ });
366
+
367
+ hotkeys.registerHotkey({
368
+ key: "ctrl+shift+s",
369
+ action: () => inlineFormatting.toggleStrikethrough(),
370
+ description: "Strikethrough",
371
+ group: "formatting"
372
+ });
373
+
374
+ hotkeys.registerHotkey({
375
+ key: "ctrl+shift+h",
376
+ action: () => inlineFormatting.toggleHighlight(),
377
+ description: "Highlight",
378
+ group: "formatting"
379
+ });
380
+
381
+ hotkeys.registerHotkey({
382
+ key: "ctrl+u",
383
+ action: () => inlineFormatting.toggleUnderline(),
384
+ description: "Underline",
385
+ group: "formatting"
386
+ });
387
+
388
+ // === Heading Hotkeys (Ctrl+0 through Ctrl+6) ===
389
+ // These use wrapper functions that handle list items by converting to paragraph first
390
+ hotkeys.registerHotkey({
391
+ key: "ctrl+0",
392
+ action: () => setHeadingLevelWithListHandling(0),
393
+ description: "Convert to paragraph",
394
+ group: "headings"
395
+ });
396
+
397
+ hotkeys.registerHotkey({
398
+ key: "ctrl+1",
399
+ action: () => setHeadingLevelWithListHandling(1),
400
+ description: "Convert to Heading 1",
401
+ group: "headings"
402
+ });
403
+
404
+ hotkeys.registerHotkey({
405
+ key: "ctrl+2",
406
+ action: () => setHeadingLevelWithListHandling(2),
407
+ description: "Convert to Heading 2",
408
+ group: "headings"
409
+ });
410
+
411
+ hotkeys.registerHotkey({
412
+ key: "ctrl+3",
413
+ action: () => setHeadingLevelWithListHandling(3),
414
+ description: "Convert to Heading 3",
415
+ group: "headings"
416
+ });
417
+
418
+ hotkeys.registerHotkey({
419
+ key: "ctrl+4",
420
+ action: () => setHeadingLevelWithListHandling(4),
421
+ description: "Convert to Heading 4",
422
+ group: "headings"
423
+ });
424
+
425
+ hotkeys.registerHotkey({
426
+ key: "ctrl+5",
427
+ action: () => setHeadingLevelWithListHandling(5),
428
+ description: "Convert to Heading 5",
429
+ group: "headings"
430
+ });
431
+
432
+ hotkeys.registerHotkey({
433
+ key: "ctrl+6",
434
+ action: () => setHeadingLevelWithListHandling(6),
435
+ description: "Convert to Heading 6",
436
+ group: "headings"
437
+ });
438
+
439
+ // Heading level cycling hotkeys (also handle list items)
440
+ // Ctrl+< decreases heading (H1 -> H2 -> ... -> H6 -> P)
441
+ hotkeys.registerHotkey({
442
+ key: "ctrl+<",
443
+ action: () => decreaseHeadingLevelWithListHandling(),
444
+ description: "Decrease heading level",
445
+ group: "headings"
446
+ });
447
+
448
+ // Ctrl+> increases heading (P -> H6 -> H5 -> ... -> H1)
449
+ hotkeys.registerHotkey({
450
+ key: "ctrl+>",
451
+ action: () => increaseHeadingLevelWithListHandling(),
452
+ description: "Increase heading level",
453
+ group: "headings"
454
+ });
455
+
456
+ // === List Hotkeys ===
457
+ hotkeys.registerHotkey({
458
+ key: "ctrl+shift+[",
459
+ action: () => lists.toggleUnorderedList(),
460
+ description: "Toggle bullet list",
461
+ group: "lists"
462
+ });
463
+
464
+ hotkeys.registerHotkey({
465
+ key: "ctrl+shift+]",
466
+ action: () => lists.toggleOrderedList(),
467
+ description: "Toggle numbered list",
468
+ group: "lists"
469
+ });
470
+
471
+ // Tab/Shift+Tab for list indentation (registered for help display - actual handling is in onKeyDown)
472
+ hotkeys.registerHotkey({
473
+ key: "tab",
474
+ action: () => {}, // Handled in onKeyDown
475
+ description: "Indent list item",
476
+ group: "lists"
477
+ });
478
+
479
+ hotkeys.registerHotkey({
480
+ key: "shift+tab",
481
+ action: () => {}, // Handled in onKeyDown
482
+ description: "Outdent list item",
483
+ group: "lists"
484
+ });
485
+
486
+ // === Code Block Hotkeys ===
487
+ hotkeys.registerHotkey({
488
+ key: "ctrl+shift+k",
489
+ action: () => toggleCodeBlockWithListHandling(),
490
+ description: "Toggle code block",
491
+ group: "blocks"
492
+ });
493
+
494
+ // Exit code block (registered for help display - actual handling is in CodeViewer)
495
+ hotkeys.registerHotkey({
496
+ key: "ctrl+enter",
497
+ action: () => {}, // Handled by CodeViewer's onKeyDown
498
+ description: "Exit code block",
499
+ group: "blocks"
500
+ });
501
+
502
+ // Language cycling for code blocks (registered for help display - actual handling is in onKeyDown)
503
+ hotkeys.registerHotkey({
504
+ key: "ctrl+alt+l",
505
+ action: () => {}, // Handled in onKeyDown when in code block
506
+ description: "Cycle language (in code block)",
507
+ group: "blocks"
508
+ });
509
+
510
+ hotkeys.registerHotkey({
511
+ key: "ctrl+alt+shift+l",
512
+ action: () => {}, // Handled by CodeViewer
513
+ description: "Search language (in code block)",
514
+ group: "blocks"
515
+ });
516
+
517
+ // === Blockquote Hotkeys ===
518
+ hotkeys.registerHotkey({
519
+ key: "ctrl+shift+q",
520
+ action: () => blockquotes.toggleBlockquote(),
521
+ description: "Toggle blockquote",
522
+ group: "blocks"
523
+ });
524
+
525
+ // === Horizontal Rule Hotkey ===
526
+ hotkeys.registerHotkey({
527
+ key: "ctrl+shift+enter",
528
+ action: () => insertHorizontalRule(),
529
+ description: "Insert horizontal rule",
530
+ group: "blocks"
531
+ });
532
+
533
+ // === Link Hotkeys ===
534
+ hotkeys.registerHotkey({
535
+ key: "ctrl+k",
536
+ action: () => links.insertLink(),
537
+ description: "Insert/edit link",
538
+ group: "formatting"
539
+ });
540
+
541
+ // === Table Hotkeys ===
542
+ hotkeys.registerHotkey({
543
+ key: "ctrl+alt+shift+t",
544
+ action: () => tables.insertTable(),
545
+ description: "Insert table",
546
+ group: "tables"
547
+ });
548
+
549
+ // Table insert operations (only work when in table)
550
+ hotkeys.registerHotkey({
551
+ key: "ctrl+alt+shift+up",
552
+ action: () => { if (tables.isInTable()) tables.insertRowAbove(); },
553
+ description: "Insert row above",
554
+ group: "tables"
555
+ });
556
+
557
+ hotkeys.registerHotkey({
558
+ key: "ctrl+alt+shift+down",
559
+ action: () => { if (tables.isInTable()) tables.insertRowBelow(); },
560
+ description: "Insert row below",
561
+ group: "tables"
562
+ });
563
+
564
+ hotkeys.registerHotkey({
565
+ key: "ctrl+alt+shift+left",
566
+ action: () => { if (tables.isInTable()) tables.insertColumnLeft(); },
567
+ description: "Insert column left",
568
+ group: "tables"
569
+ });
570
+
571
+ hotkeys.registerHotkey({
572
+ key: "ctrl+alt+shift+right",
573
+ action: () => { if (tables.isInTable()) tables.insertColumnRight(); },
574
+ description: "Insert column right",
575
+ group: "tables"
576
+ });
577
+
578
+ // Table delete operations (only work when in table)
579
+ hotkeys.registerHotkey({
580
+ key: "ctrl+alt+backspace",
581
+ action: () => { if (tables.isInTable()) tables.deleteCurrentRow(); },
582
+ description: "Delete row",
583
+ group: "tables"
584
+ });
585
+
586
+ hotkeys.registerHotkey({
587
+ key: "ctrl+shift+backspace",
588
+ action: () => { if (tables.isInTable()) tables.deleteCurrentColumn(); },
589
+ description: "Delete column",
590
+ group: "tables"
591
+ });
592
+
593
+ hotkeys.registerHotkey({
594
+ key: "ctrl+alt+shift+backspace",
595
+ action: () => { if (tables.isInTable()) tables.deleteTable(); },
596
+ description: "Delete table",
597
+ group: "tables"
598
+ });
599
+
600
+ // Table alignment hotkeys (only work when in table)
601
+ hotkeys.registerHotkey({
602
+ key: "ctrl+alt+l",
603
+ action: () => { if (tables.isInTable()) tables.setColumnAlignmentLeft(); },
604
+ description: "Align column left",
605
+ group: "tables"
606
+ });
607
+
608
+ hotkeys.registerHotkey({
609
+ key: "ctrl+alt+c",
610
+ action: () => { if (tables.isInTable()) tables.setColumnAlignmentCenter(); },
611
+ description: "Align column center",
612
+ group: "tables"
613
+ });
614
+
615
+ hotkeys.registerHotkey({
616
+ key: "ctrl+alt+r",
617
+ action: () => { if (tables.isInTable()) tables.setColumnAlignmentRight(); },
618
+ description: "Align column right",
619
+ group: "tables"
620
+ });
621
+
622
+ // Help hotkey (Ctrl+? is handled specially in handleKeyDown)
623
+ // This registration is for the help display list
624
+ hotkeys.registerHotkey({
625
+ key: "ctrl+?",
626
+ action: () => {
627
+ isShowingHotkeyHelp.value = true;
628
+ },
629
+ description: "Show keyboard shortcuts",
630
+ group: "other"
631
+ });
632
+ }
633
+
634
+ /**
635
+ * Insert a horizontal rule after the current block element
636
+ * Creates an <hr> element followed by a new paragraph for continued editing
637
+ */
638
+ function insertHorizontalRule(): void {
639
+ if (!contentRef.value) return;
640
+
641
+ const sel = window.getSelection();
642
+ if (!sel || sel.rangeCount === 0) return;
643
+
644
+ // Find the current block element containing the cursor
645
+ let node: Node | null = sel.getRangeAt(0).startContainer;
646
+ let blockElement: HTMLElement | null = null;
647
+
648
+ // Walk up to find a block-level element
649
+ while (node && node !== contentRef.value) {
650
+ const element = node as HTMLElement;
651
+ const tagName = element.tagName?.toUpperCase();
652
+
653
+ // Check if this is a block element (p, h1-h6, li, blockquote, etc.)
654
+ if (tagName === "P" || /^H[1-6]$/.test(tagName) || tagName === "LI" || tagName === "BLOCKQUOTE") {
655
+ blockElement = element;
656
+ break;
657
+ }
658
+
659
+ // Also check for code block wrapper
660
+ if (element.hasAttribute?.("data-code-block-id")) {
661
+ blockElement = element;
662
+ break;
663
+ }
664
+
665
+ node = element.parentElement;
666
+ }
667
+
668
+ // If no block element found, use the contentRef itself as reference
669
+ const insertAfter = blockElement || contentRef.value.lastElementChild;
670
+
671
+ if (!insertAfter) {
672
+ // Empty editor - just add hr and paragraph
673
+ const hr = document.createElement("hr");
674
+ const p = document.createElement("p");
675
+ p.appendChild(document.createElement("br"));
676
+ contentRef.value.appendChild(hr);
677
+ contentRef.value.appendChild(p);
678
+ } else {
679
+ // Insert hr after the current block
680
+ const hr = document.createElement("hr");
681
+ const p = document.createElement("p");
682
+ p.appendChild(document.createElement("br"));
683
+
684
+ // Insert after the block element (or its parent list if in a list item)
685
+ let insertionPoint: Element = insertAfter;
686
+ if (insertAfter.tagName?.toUpperCase() === "LI") {
687
+ // If in a list item, insert after the entire list
688
+ const parentList = insertAfter.closest("ul, ol");
689
+ if (parentList) {
690
+ insertionPoint = parentList;
691
+ }
692
+ }
693
+
694
+ insertionPoint.parentNode?.insertBefore(hr, insertionPoint.nextSibling);
695
+ hr.parentNode?.insertBefore(p, hr.nextSibling);
696
+ }
697
+
698
+ // Position cursor in the new paragraph
699
+ nextTick(() => {
700
+ const newParagraph = contentRef.value?.querySelector("hr + p");
701
+ if (newParagraph) {
702
+ const range = document.createRange();
703
+ range.selectNodeContents(newParagraph);
704
+ range.collapse(true);
705
+ const newSel = window.getSelection();
706
+ newSel?.removeAllRanges();
707
+ newSel?.addRange(range);
708
+ }
709
+ });
710
+
711
+ sync.debouncedSyncFromHtml();
712
+ }
713
+
714
+ /**
715
+ * Insert a tab character at the current cursor position
716
+ */
717
+ function insertTabCharacter(): void {
718
+ if (!contentRef.value) return;
719
+
720
+ const sel = window.getSelection();
721
+ if (!sel || sel.rangeCount === 0) return;
722
+
723
+ const range = sel.getRangeAt(0);
724
+ range.deleteContents();
725
+
726
+ const tabNode = document.createTextNode("\t");
727
+ range.insertNode(tabNode);
728
+
729
+ // Position cursor AFTER the tab node
730
+ range.setStartAfter(tabNode);
731
+ range.setEndAfter(tabNode);
732
+
733
+ sel.removeAllRanges();
734
+ sel.addRange(range);
735
+
736
+ // Trigger content sync AFTER cursor is positioned
737
+ sync.debouncedSyncFromHtml();
738
+ }
739
+
740
+ /**
741
+ * Handle input events from contenteditable
742
+ * Checks for markdown patterns (e.g., "# " for headings, "- " for lists) and converts immediately
743
+ * Note: Code fence patterns (```) are only converted on Enter key press, not on input
744
+ */
745
+ function onInput(): void {
746
+ // Update character count immediately for responsive UI
747
+ updateCharCount();
748
+
749
+ // Check for heading pattern (e.g., "# " -> H1)
750
+ let converted = headings.checkAndConvertHeadingPattern();
751
+
752
+ // Check for list pattern (e.g., "- " -> ul, "1. " -> ol)
753
+ if (!converted) {
754
+ converted = lists.checkAndConvertListPattern();
755
+ }
756
+
757
+ // If a pattern was converted, the content change callback already triggers sync
758
+ // Otherwise, sync as normal
759
+ if (!converted) {
760
+ sync.debouncedSyncFromHtml();
761
+ }
762
+ }
763
+
764
+ /**
765
+ * Check if cursor is at the start of a block element (paragraph, heading, etc.)
766
+ * Returns the block element if cursor is at position 0, null otherwise
767
+ */
768
+ function getCursorBlockAtStart(): HTMLElement | null {
769
+ const sel = window.getSelection();
770
+ if (!sel || sel.rangeCount === 0) return null;
771
+
772
+ const range = sel.getRangeAt(0);
773
+
774
+ // Check if range is collapsed (no selection, just cursor)
775
+ if (!range.collapsed) return null;
776
+
777
+ // Get the cursor's offset - must be at position 0
778
+ if (range.startOffset !== 0) return null;
779
+
780
+ // Find the containing block element
781
+ let node: Node | null = range.startContainer;
782
+
783
+ // If we're in a text node, check if we're at the very beginning
784
+ if (node.nodeType === Node.TEXT_NODE) {
785
+ // Must be at position 0 of the text node
786
+ if (range.startOffset !== 0) return null;
787
+
788
+ // Check if this text node is the first content in its parent block
789
+ const parent = node.parentElement;
790
+ if (!parent) return null;
791
+
792
+ // Walk up to find a block element
793
+ node = parent;
794
+ }
795
+
796
+ // Find the block-level element (p, h1-h6, etc.)
797
+ while (node && node !== contentRef.value) {
798
+ const element = node as HTMLElement;
799
+ const tagName = element.tagName?.toUpperCase();
800
+
801
+ // Check if this is a block element
802
+ if (tagName === "P" || /^H[1-6]$/.test(tagName)) {
803
+ // Verify cursor is truly at the start by checking the range position
804
+ const blockRange = document.createRange();
805
+ blockRange.selectNodeContents(element);
806
+ blockRange.collapse(true);
807
+
808
+ // Compare the cursor position with the block start
809
+ if (range.compareBoundaryPoints(Range.START_TO_START, blockRange) === 0) {
810
+ return element;
811
+ }
812
+ return null;
813
+ }
814
+
815
+ node = element.parentElement;
816
+ }
817
+
818
+ return null;
819
+ }
820
+
821
+ /**
822
+ * Handle Backspace at the start of a paragraph after a code block
823
+ * Moves cursor into the code block instead of deleting
824
+ */
825
+ function handleBackspaceIntoCodeBlock(): boolean {
826
+ const block = getCursorBlockAtStart();
827
+ if (!block) return false;
828
+
829
+ // Check if the previous sibling is a code block wrapper
830
+ const previousSibling = block.previousElementSibling;
831
+ if (!previousSibling?.hasAttribute("data-code-block-id")) return false;
832
+
833
+ // Check if the current block is empty (only contains <br> or whitespace)
834
+ const isEmpty = !block.textContent?.trim();
835
+
836
+ // Find the CodeViewer's contenteditable pre element inside the wrapper
837
+ const codeViewerPre = previousSibling.querySelector("pre[contenteditable='true']");
838
+ if (!codeViewerPre) return false;
839
+
840
+ // If the paragraph is empty, remove it
841
+ if (isEmpty) {
842
+ block.remove();
843
+ }
844
+
845
+ // Focus the CodeViewer and position cursor at the end
846
+ nextTick(() => {
847
+ (codeViewerPre as HTMLElement).focus();
848
+
849
+ // Position cursor at the end of the code content
850
+ const selection = window.getSelection();
851
+ if (selection) {
852
+ const range = document.createRange();
853
+ range.selectNodeContents(codeViewerPre);
854
+ range.collapse(false); // Collapse to end
855
+ selection.removeAllRanges();
856
+ selection.addRange(range);
857
+ }
858
+ });
859
+
860
+ sync.debouncedSyncFromHtml();
861
+ return true;
862
+ }
863
+
864
+ /**
865
+ * Handle keydown events
866
+ * Handles Enter for code block continuation/exit and list continuation, Tab/Shift+Tab for indentation
867
+ */
868
+ function onKeyDown(event: KeyboardEvent): void {
869
+ // Don't handle events that originate from inside code block wrappers
870
+ // (CodeViewer handles its own keyboard events like Ctrl+A for select-all)
871
+ const target = event.target as HTMLElement | null;
872
+ const isInCodeBlock = target?.closest("[data-code-block-id]");
873
+
874
+ // SPECIAL CASE: Handle Ctrl+Alt+L for code block language cycling
875
+ // This is a fallback in case the CodeViewer's handler doesn't receive the event
876
+ // (Can happen due to event propagation issues in nested contenteditable elements)
877
+ const isCtrlAltL = (event.ctrlKey || event.metaKey) && event.altKey && event.key.toLowerCase() === "l";
878
+
879
+ if (isInCodeBlock && isCtrlAltL) {
880
+ event.preventDefault();
881
+ event.stopPropagation();
882
+
883
+ // Find the code block ID and cycle its language
884
+ const wrapper = target?.closest("[data-code-block-id]");
885
+ const codeBlockId = wrapper?.getAttribute("data-code-block-id");
886
+
887
+ if (codeBlockId) {
888
+ const state = codeBlocks.codeBlocks.get(codeBlockId);
889
+
890
+ if (state) {
891
+ // Cycle through available formats based on current language
892
+ const currentLang = state.language || "yaml";
893
+ let nextLang: string;
894
+
895
+ if (currentLang === "json" || currentLang === "yaml") {
896
+ // Cycle: yaml -> json -> yaml (YAML/JSON only)
897
+ nextLang = currentLang === "yaml" ? "json" : "yaml";
898
+ } else if (currentLang === "text" || currentLang === "markdown") {
899
+ // Cycle: text -> markdown -> text
900
+ nextLang = currentLang === "text" ? "markdown" : "text";
901
+ } else {
902
+ // For other languages, don't cycle
903
+ nextLang = currentLang;
904
+ }
905
+
906
+ if (nextLang !== currentLang) {
907
+ codeBlocks.updateCodeBlockLanguage(codeBlockId, nextLang);
908
+ }
909
+ }
910
+ }
911
+ return;
912
+ }
913
+
914
+ if (isInCodeBlock) {
915
+ return;
916
+ }
917
+
918
+ // Handle Backspace at the start of a paragraph after a code block
919
+ if (event.key === "Backspace" && !event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey) {
920
+ if (handleBackspaceIntoCodeBlock()) {
921
+ event.preventDefault();
922
+ return;
923
+ }
924
+ }
925
+
926
+ // Handle arrow keys in tables (without modifiers for simple navigation)
927
+ if ((event.key === "ArrowUp" || event.key === "ArrowDown") &&
928
+ !event.ctrlKey && !event.altKey && !event.shiftKey && !event.metaKey) {
929
+ if (tables.isInTable()) {
930
+ // Always navigate to the same column in the adjacent row
931
+ const handled = event.key === "ArrowUp"
932
+ ? tables.navigateToCellAbove()
933
+ : tables.navigateToCellBelow();
934
+ if (handled) {
935
+ event.preventDefault();
936
+ return;
937
+ }
938
+ }
939
+ }
940
+
941
+ // Handle Enter key for code block, table, and list continuation
942
+ if (event.key === "Enter" && !event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey) {
943
+ // Check for code fence pattern first (e.g., "```javascript" -> code block)
944
+ // This triggers only on Enter, allowing user to type full language before conversion
945
+ const convertedToCodeBlock = codeBlocks.checkAndConvertCodeBlockPattern();
946
+ if (convertedToCodeBlock) {
947
+ event.preventDefault();
948
+ return;
949
+ }
950
+
951
+ // Check existing code block - Enter inserts newline, or exits after double-Enter at end
952
+ const handledByCodeBlock = codeBlocks.handleCodeBlockEnter();
953
+ if (handledByCodeBlock) {
954
+ event.preventDefault();
955
+ return;
956
+ }
957
+
958
+ // Check if in a table - Enter moves to cell below or creates new row
959
+ if (tables.isInTable()) {
960
+ event.preventDefault();
961
+ tables.handleTableEnter();
962
+ return;
963
+ }
964
+
965
+ // Then check lists
966
+ const handled = lists.handleListEnter();
967
+ if (handled) {
968
+ event.preventDefault();
969
+ return;
970
+ }
971
+ }
972
+
973
+ // Handle Tab key - always prevent default to keep focus in editor
974
+ if (event.key === "Tab" && !event.ctrlKey && !event.altKey && !event.metaKey) {
975
+ event.preventDefault();
976
+
977
+ // Check if in a table first - Tab navigates between cells
978
+ if (tables.isInTable()) {
979
+ tables.handleTableTab(event.shiftKey);
980
+ return;
981
+ }
982
+
983
+ if (event.shiftKey) {
984
+ // Shift+Tab - outdent if in list, otherwise do nothing
985
+ lists.outdentListItem();
986
+ } else {
987
+ // Tab - indent if in list, otherwise insert tab character
988
+ const handled = lists.indentListItem();
989
+ if (!handled) {
990
+ // Not in a list - insert a tab character at cursor position
991
+ insertTabCharacter();
992
+ }
993
+ }
994
+ return;
995
+ }
996
+
997
+ // Let hotkeys handle other keys - if not handled, default browser behavior occurs
998
+ hotkeys.handleKeyDown(event);
999
+ }
1000
+
1001
+ /**
1002
+ * Handle blur events - sync immediately
1003
+ */
1004
+ function onBlur(): void {
1005
+ sync.syncFromHtml();
1006
+ }
1007
+
1008
+ /**
1009
+ * Set markdown content from external source
1010
+ */
1011
+ function setMarkdown(markdown: string): void {
1012
+ sync.syncFromMarkdown(markdown);
1013
+
1014
+ // Update the contenteditable element
1015
+ nextTick(() => {
1016
+ if (contentRef.value) {
1017
+ contentRef.value.innerHTML = sync.renderedHtml.value;
1018
+ updateCharCount();
1019
+ }
1020
+ });
1021
+ }
1022
+
1023
+ /**
1024
+ * Show hotkey help popover
1025
+ */
1026
+ function showHotkeyHelp(): void {
1027
+ isShowingHotkeyHelp.value = true;
1028
+ }
1029
+
1030
+ /**
1031
+ * Hide hotkey help popover
1032
+ */
1033
+ function hideHotkeyHelp(): void {
1034
+ isShowingHotkeyHelp.value = false;
1035
+ }
1036
+
1037
+ // Initialize with initial value
1038
+ if (initialValue) {
1039
+ sync.syncFromMarkdown(initialValue);
1040
+ }
1041
+
1042
+ return {
1043
+ // From sync
1044
+ renderedHtml: sync.renderedHtml,
1045
+ isInternalUpdate: sync.isInternalUpdate,
1046
+
1047
+ // State
1048
+ isShowingHotkeyHelp,
1049
+ charCount,
1050
+
1051
+ // Event handlers
1052
+ onInput,
1053
+ onKeyDown,
1054
+ onBlur,
1055
+
1056
+ // External value updates
1057
+ setMarkdown,
1058
+
1059
+ // Formatting actions
1060
+ insertHorizontalRule,
1061
+
1062
+ // Hotkey help
1063
+ showHotkeyHelp,
1064
+ hideHotkeyHelp,
1065
+ hotkeyDefinitions,
1066
+
1067
+ // Feature access
1068
+ headings,
1069
+ inlineFormatting,
1070
+ links,
1071
+ lists,
1072
+ codeBlocks,
1073
+ codeBlockManager,
1074
+ blockquotes,
1075
+ tables
1076
+ };
1077
+ }