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,1068 @@
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
+ // Computed character count
327
+ const charCount = computed(() => {
328
+ return contentRef.value?.textContent?.length || 0;
329
+ });
330
+
331
+ // Reactive hotkey definitions for UI
332
+ const hotkeyDefinitions = computed(() => {
333
+ return hotkeys.getHotkeyDefinitions();
334
+ });
335
+
336
+ /**
337
+ * Register default hotkeys for all features
338
+ */
339
+ function registerDefaultHotkeys(): void {
340
+ // === Inline Formatting Hotkeys ===
341
+ hotkeys.registerHotkey({
342
+ key: "ctrl+b",
343
+ action: () => inlineFormatting.toggleBold(),
344
+ description: "Bold",
345
+ group: "formatting"
346
+ });
347
+
348
+ hotkeys.registerHotkey({
349
+ key: "ctrl+i",
350
+ action: () => inlineFormatting.toggleItalic(),
351
+ description: "Italic",
352
+ group: "formatting"
353
+ });
354
+
355
+ hotkeys.registerHotkey({
356
+ key: "ctrl+e",
357
+ action: () => inlineFormatting.toggleInlineCode(),
358
+ description: "Inline code",
359
+ group: "formatting"
360
+ });
361
+
362
+ hotkeys.registerHotkey({
363
+ key: "ctrl+shift+s",
364
+ action: () => inlineFormatting.toggleStrikethrough(),
365
+ description: "Strikethrough",
366
+ group: "formatting"
367
+ });
368
+
369
+ hotkeys.registerHotkey({
370
+ key: "ctrl+shift+h",
371
+ action: () => inlineFormatting.toggleHighlight(),
372
+ description: "Highlight",
373
+ group: "formatting"
374
+ });
375
+
376
+ hotkeys.registerHotkey({
377
+ key: "ctrl+u",
378
+ action: () => inlineFormatting.toggleUnderline(),
379
+ description: "Underline",
380
+ group: "formatting"
381
+ });
382
+
383
+ // === Heading Hotkeys (Ctrl+0 through Ctrl+6) ===
384
+ // These use wrapper functions that handle list items by converting to paragraph first
385
+ hotkeys.registerHotkey({
386
+ key: "ctrl+0",
387
+ action: () => setHeadingLevelWithListHandling(0),
388
+ description: "Convert to paragraph",
389
+ group: "headings"
390
+ });
391
+
392
+ hotkeys.registerHotkey({
393
+ key: "ctrl+1",
394
+ action: () => setHeadingLevelWithListHandling(1),
395
+ description: "Convert to Heading 1",
396
+ group: "headings"
397
+ });
398
+
399
+ hotkeys.registerHotkey({
400
+ key: "ctrl+2",
401
+ action: () => setHeadingLevelWithListHandling(2),
402
+ description: "Convert to Heading 2",
403
+ group: "headings"
404
+ });
405
+
406
+ hotkeys.registerHotkey({
407
+ key: "ctrl+3",
408
+ action: () => setHeadingLevelWithListHandling(3),
409
+ description: "Convert to Heading 3",
410
+ group: "headings"
411
+ });
412
+
413
+ hotkeys.registerHotkey({
414
+ key: "ctrl+4",
415
+ action: () => setHeadingLevelWithListHandling(4),
416
+ description: "Convert to Heading 4",
417
+ group: "headings"
418
+ });
419
+
420
+ hotkeys.registerHotkey({
421
+ key: "ctrl+5",
422
+ action: () => setHeadingLevelWithListHandling(5),
423
+ description: "Convert to Heading 5",
424
+ group: "headings"
425
+ });
426
+
427
+ hotkeys.registerHotkey({
428
+ key: "ctrl+6",
429
+ action: () => setHeadingLevelWithListHandling(6),
430
+ description: "Convert to Heading 6",
431
+ group: "headings"
432
+ });
433
+
434
+ // Heading level cycling hotkeys (also handle list items)
435
+ // Ctrl+< decreases heading (H1 -> H2 -> ... -> H6 -> P)
436
+ hotkeys.registerHotkey({
437
+ key: "ctrl+<",
438
+ action: () => decreaseHeadingLevelWithListHandling(),
439
+ description: "Decrease heading level",
440
+ group: "headings"
441
+ });
442
+
443
+ // Ctrl+> increases heading (P -> H6 -> H5 -> ... -> H1)
444
+ hotkeys.registerHotkey({
445
+ key: "ctrl+>",
446
+ action: () => increaseHeadingLevelWithListHandling(),
447
+ description: "Increase heading level",
448
+ group: "headings"
449
+ });
450
+
451
+ // === List Hotkeys ===
452
+ hotkeys.registerHotkey({
453
+ key: "ctrl+shift+[",
454
+ action: () => lists.toggleUnorderedList(),
455
+ description: "Toggle bullet list",
456
+ group: "lists"
457
+ });
458
+
459
+ hotkeys.registerHotkey({
460
+ key: "ctrl+shift+]",
461
+ action: () => lists.toggleOrderedList(),
462
+ description: "Toggle numbered list",
463
+ group: "lists"
464
+ });
465
+
466
+ // Tab/Shift+Tab for list indentation (registered for help display - actual handling is in onKeyDown)
467
+ hotkeys.registerHotkey({
468
+ key: "tab",
469
+ action: () => {}, // Handled in onKeyDown
470
+ description: "Indent list item",
471
+ group: "lists"
472
+ });
473
+
474
+ hotkeys.registerHotkey({
475
+ key: "shift+tab",
476
+ action: () => {}, // Handled in onKeyDown
477
+ description: "Outdent list item",
478
+ group: "lists"
479
+ });
480
+
481
+ // === Code Block Hotkeys ===
482
+ hotkeys.registerHotkey({
483
+ key: "ctrl+shift+k",
484
+ action: () => toggleCodeBlockWithListHandling(),
485
+ description: "Toggle code block",
486
+ group: "blocks"
487
+ });
488
+
489
+ // Exit code block (registered for help display - actual handling is in CodeViewer)
490
+ hotkeys.registerHotkey({
491
+ key: "ctrl+enter",
492
+ action: () => {}, // Handled by CodeViewer's onKeyDown
493
+ description: "Exit code block",
494
+ group: "blocks"
495
+ });
496
+
497
+ // Language cycling for code blocks (registered for help display - actual handling is in onKeyDown)
498
+ hotkeys.registerHotkey({
499
+ key: "ctrl+alt+l",
500
+ action: () => {}, // Handled in onKeyDown when in code block
501
+ description: "Cycle language (in code block)",
502
+ group: "blocks"
503
+ });
504
+
505
+ hotkeys.registerHotkey({
506
+ key: "ctrl+alt+shift+l",
507
+ action: () => {}, // Handled by CodeViewer
508
+ description: "Search language (in code block)",
509
+ group: "blocks"
510
+ });
511
+
512
+ // === Blockquote Hotkeys ===
513
+ hotkeys.registerHotkey({
514
+ key: "ctrl+shift+q",
515
+ action: () => blockquotes.toggleBlockquote(),
516
+ description: "Toggle blockquote",
517
+ group: "blocks"
518
+ });
519
+
520
+ // === Horizontal Rule Hotkey ===
521
+ hotkeys.registerHotkey({
522
+ key: "ctrl+shift+enter",
523
+ action: () => insertHorizontalRule(),
524
+ description: "Insert horizontal rule",
525
+ group: "blocks"
526
+ });
527
+
528
+ // === Link Hotkeys ===
529
+ hotkeys.registerHotkey({
530
+ key: "ctrl+k",
531
+ action: () => links.insertLink(),
532
+ description: "Insert/edit link",
533
+ group: "formatting"
534
+ });
535
+
536
+ // === Table Hotkeys ===
537
+ hotkeys.registerHotkey({
538
+ key: "ctrl+alt+shift+t",
539
+ action: () => tables.insertTable(),
540
+ description: "Insert table",
541
+ group: "tables"
542
+ });
543
+
544
+ // Table insert operations (only work when in table)
545
+ hotkeys.registerHotkey({
546
+ key: "ctrl+alt+shift+up",
547
+ action: () => { if (tables.isInTable()) tables.insertRowAbove(); },
548
+ description: "Insert row above",
549
+ group: "tables"
550
+ });
551
+
552
+ hotkeys.registerHotkey({
553
+ key: "ctrl+alt+shift+down",
554
+ action: () => { if (tables.isInTable()) tables.insertRowBelow(); },
555
+ description: "Insert row below",
556
+ group: "tables"
557
+ });
558
+
559
+ hotkeys.registerHotkey({
560
+ key: "ctrl+alt+shift+left",
561
+ action: () => { if (tables.isInTable()) tables.insertColumnLeft(); },
562
+ description: "Insert column left",
563
+ group: "tables"
564
+ });
565
+
566
+ hotkeys.registerHotkey({
567
+ key: "ctrl+alt+shift+right",
568
+ action: () => { if (tables.isInTable()) tables.insertColumnRight(); },
569
+ description: "Insert column right",
570
+ group: "tables"
571
+ });
572
+
573
+ // Table delete operations (only work when in table)
574
+ hotkeys.registerHotkey({
575
+ key: "ctrl+alt+backspace",
576
+ action: () => { if (tables.isInTable()) tables.deleteCurrentRow(); },
577
+ description: "Delete row",
578
+ group: "tables"
579
+ });
580
+
581
+ hotkeys.registerHotkey({
582
+ key: "ctrl+shift+backspace",
583
+ action: () => { if (tables.isInTable()) tables.deleteCurrentColumn(); },
584
+ description: "Delete column",
585
+ group: "tables"
586
+ });
587
+
588
+ hotkeys.registerHotkey({
589
+ key: "ctrl+alt+shift+backspace",
590
+ action: () => { if (tables.isInTable()) tables.deleteTable(); },
591
+ description: "Delete table",
592
+ group: "tables"
593
+ });
594
+
595
+ // Table alignment hotkeys (only work when in table)
596
+ hotkeys.registerHotkey({
597
+ key: "ctrl+alt+l",
598
+ action: () => { if (tables.isInTable()) tables.setColumnAlignmentLeft(); },
599
+ description: "Align column left",
600
+ group: "tables"
601
+ });
602
+
603
+ hotkeys.registerHotkey({
604
+ key: "ctrl+alt+c",
605
+ action: () => { if (tables.isInTable()) tables.setColumnAlignmentCenter(); },
606
+ description: "Align column center",
607
+ group: "tables"
608
+ });
609
+
610
+ hotkeys.registerHotkey({
611
+ key: "ctrl+alt+r",
612
+ action: () => { if (tables.isInTable()) tables.setColumnAlignmentRight(); },
613
+ description: "Align column right",
614
+ group: "tables"
615
+ });
616
+
617
+ // Help hotkey (Ctrl+? is handled specially in handleKeyDown)
618
+ // This registration is for the help display list
619
+ hotkeys.registerHotkey({
620
+ key: "ctrl+?",
621
+ action: () => {
622
+ isShowingHotkeyHelp.value = true;
623
+ },
624
+ description: "Show keyboard shortcuts",
625
+ group: "other"
626
+ });
627
+ }
628
+
629
+ /**
630
+ * Insert a horizontal rule after the current block element
631
+ * Creates an <hr> element followed by a new paragraph for continued editing
632
+ */
633
+ function insertHorizontalRule(): void {
634
+ if (!contentRef.value) return;
635
+
636
+ const sel = window.getSelection();
637
+ if (!sel || sel.rangeCount === 0) return;
638
+
639
+ // Find the current block element containing the cursor
640
+ let node: Node | null = sel.getRangeAt(0).startContainer;
641
+ let blockElement: HTMLElement | null = null;
642
+
643
+ // Walk up to find a block-level element
644
+ while (node && node !== contentRef.value) {
645
+ const element = node as HTMLElement;
646
+ const tagName = element.tagName?.toUpperCase();
647
+
648
+ // Check if this is a block element (p, h1-h6, li, blockquote, etc.)
649
+ if (tagName === "P" || /^H[1-6]$/.test(tagName) || tagName === "LI" || tagName === "BLOCKQUOTE") {
650
+ blockElement = element;
651
+ break;
652
+ }
653
+
654
+ // Also check for code block wrapper
655
+ if (element.hasAttribute?.("data-code-block-id")) {
656
+ blockElement = element;
657
+ break;
658
+ }
659
+
660
+ node = element.parentElement;
661
+ }
662
+
663
+ // If no block element found, use the contentRef itself as reference
664
+ const insertAfter = blockElement || contentRef.value.lastElementChild;
665
+
666
+ if (!insertAfter) {
667
+ // Empty editor - just add hr and paragraph
668
+ const hr = document.createElement("hr");
669
+ const p = document.createElement("p");
670
+ p.appendChild(document.createElement("br"));
671
+ contentRef.value.appendChild(hr);
672
+ contentRef.value.appendChild(p);
673
+ } else {
674
+ // Insert hr after the current block
675
+ const hr = document.createElement("hr");
676
+ const p = document.createElement("p");
677
+ p.appendChild(document.createElement("br"));
678
+
679
+ // Insert after the block element (or its parent list if in a list item)
680
+ let insertionPoint: Element = insertAfter;
681
+ if (insertAfter.tagName?.toUpperCase() === "LI") {
682
+ // If in a list item, insert after the entire list
683
+ const parentList = insertAfter.closest("ul, ol");
684
+ if (parentList) {
685
+ insertionPoint = parentList;
686
+ }
687
+ }
688
+
689
+ insertionPoint.parentNode?.insertBefore(hr, insertionPoint.nextSibling);
690
+ hr.parentNode?.insertBefore(p, hr.nextSibling);
691
+ }
692
+
693
+ // Position cursor in the new paragraph
694
+ nextTick(() => {
695
+ const newParagraph = contentRef.value?.querySelector("hr + p");
696
+ if (newParagraph) {
697
+ const range = document.createRange();
698
+ range.selectNodeContents(newParagraph);
699
+ range.collapse(true);
700
+ const newSel = window.getSelection();
701
+ newSel?.removeAllRanges();
702
+ newSel?.addRange(range);
703
+ }
704
+ });
705
+
706
+ sync.debouncedSyncFromHtml();
707
+ }
708
+
709
+ /**
710
+ * Insert a tab character at the current cursor position
711
+ */
712
+ function insertTabCharacter(): void {
713
+ if (!contentRef.value) return;
714
+
715
+ const sel = window.getSelection();
716
+ if (!sel || sel.rangeCount === 0) return;
717
+
718
+ const range = sel.getRangeAt(0);
719
+ range.deleteContents();
720
+
721
+ const tabNode = document.createTextNode("\t");
722
+ range.insertNode(tabNode);
723
+
724
+ // Position cursor AFTER the tab node
725
+ range.setStartAfter(tabNode);
726
+ range.setEndAfter(tabNode);
727
+
728
+ sel.removeAllRanges();
729
+ sel.addRange(range);
730
+
731
+ // Trigger content sync AFTER cursor is positioned
732
+ sync.debouncedSyncFromHtml();
733
+ }
734
+
735
+ /**
736
+ * Handle input events from contenteditable
737
+ * Checks for markdown patterns (e.g., "# " for headings, "- " for lists) and converts immediately
738
+ * Note: Code fence patterns (```) are only converted on Enter key press, not on input
739
+ */
740
+ function onInput(): void {
741
+ // Check for heading pattern (e.g., "# " -> H1)
742
+ let converted = headings.checkAndConvertHeadingPattern();
743
+
744
+ // Check for list pattern (e.g., "- " -> ul, "1. " -> ol)
745
+ if (!converted) {
746
+ converted = lists.checkAndConvertListPattern();
747
+ }
748
+
749
+ // If a pattern was converted, the content change callback already triggers sync
750
+ // Otherwise, sync as normal
751
+ if (!converted) {
752
+ sync.debouncedSyncFromHtml();
753
+ }
754
+ }
755
+
756
+ /**
757
+ * Check if cursor is at the start of a block element (paragraph, heading, etc.)
758
+ * Returns the block element if cursor is at position 0, null otherwise
759
+ */
760
+ function getCursorBlockAtStart(): HTMLElement | null {
761
+ const sel = window.getSelection();
762
+ if (!sel || sel.rangeCount === 0) return null;
763
+
764
+ const range = sel.getRangeAt(0);
765
+
766
+ // Check if range is collapsed (no selection, just cursor)
767
+ if (!range.collapsed) return null;
768
+
769
+ // Get the cursor's offset - must be at position 0
770
+ if (range.startOffset !== 0) return null;
771
+
772
+ // Find the containing block element
773
+ let node: Node | null = range.startContainer;
774
+
775
+ // If we're in a text node, check if we're at the very beginning
776
+ if (node.nodeType === Node.TEXT_NODE) {
777
+ // Must be at position 0 of the text node
778
+ if (range.startOffset !== 0) return null;
779
+
780
+ // Check if this text node is the first content in its parent block
781
+ const parent = node.parentElement;
782
+ if (!parent) return null;
783
+
784
+ // Walk up to find a block element
785
+ node = parent;
786
+ }
787
+
788
+ // Find the block-level element (p, h1-h6, etc.)
789
+ while (node && node !== contentRef.value) {
790
+ const element = node as HTMLElement;
791
+ const tagName = element.tagName?.toUpperCase();
792
+
793
+ // Check if this is a block element
794
+ if (tagName === "P" || /^H[1-6]$/.test(tagName)) {
795
+ // Verify cursor is truly at the start by checking the range position
796
+ const blockRange = document.createRange();
797
+ blockRange.selectNodeContents(element);
798
+ blockRange.collapse(true);
799
+
800
+ // Compare the cursor position with the block start
801
+ if (range.compareBoundaryPoints(Range.START_TO_START, blockRange) === 0) {
802
+ return element;
803
+ }
804
+ return null;
805
+ }
806
+
807
+ node = element.parentElement;
808
+ }
809
+
810
+ return null;
811
+ }
812
+
813
+ /**
814
+ * Handle Backspace at the start of a paragraph after a code block
815
+ * Moves cursor into the code block instead of deleting
816
+ */
817
+ function handleBackspaceIntoCodeBlock(): boolean {
818
+ const block = getCursorBlockAtStart();
819
+ if (!block) return false;
820
+
821
+ // Check if the previous sibling is a code block wrapper
822
+ const previousSibling = block.previousElementSibling;
823
+ if (!previousSibling?.hasAttribute("data-code-block-id")) return false;
824
+
825
+ // Check if the current block is empty (only contains <br> or whitespace)
826
+ const isEmpty = !block.textContent?.trim();
827
+
828
+ // Find the CodeViewer's contenteditable pre element inside the wrapper
829
+ const codeViewerPre = previousSibling.querySelector("pre[contenteditable='true']");
830
+ if (!codeViewerPre) return false;
831
+
832
+ // If the paragraph is empty, remove it
833
+ if (isEmpty) {
834
+ block.remove();
835
+ }
836
+
837
+ // Focus the CodeViewer and position cursor at the end
838
+ nextTick(() => {
839
+ (codeViewerPre as HTMLElement).focus();
840
+
841
+ // Position cursor at the end of the code content
842
+ const selection = window.getSelection();
843
+ if (selection) {
844
+ const range = document.createRange();
845
+ range.selectNodeContents(codeViewerPre);
846
+ range.collapse(false); // Collapse to end
847
+ selection.removeAllRanges();
848
+ selection.addRange(range);
849
+ }
850
+ });
851
+
852
+ sync.debouncedSyncFromHtml();
853
+ return true;
854
+ }
855
+
856
+ /**
857
+ * Handle keydown events
858
+ * Handles Enter for code block continuation/exit and list continuation, Tab/Shift+Tab for indentation
859
+ */
860
+ function onKeyDown(event: KeyboardEvent): void {
861
+ // Don't handle events that originate from inside code block wrappers
862
+ // (CodeViewer handles its own keyboard events like Ctrl+A for select-all)
863
+ const target = event.target as HTMLElement;
864
+ const isInCodeBlock = target.closest("[data-code-block-id]");
865
+
866
+ // SPECIAL CASE: Handle Ctrl+Alt+L for code block language cycling
867
+ // This is a fallback in case the CodeViewer's handler doesn't receive the event
868
+ // (Can happen due to event propagation issues in nested contenteditable elements)
869
+ const isCtrlAltL = (event.ctrlKey || event.metaKey) && event.altKey && event.key.toLowerCase() === "l";
870
+
871
+ if (isInCodeBlock && isCtrlAltL) {
872
+ event.preventDefault();
873
+ event.stopPropagation();
874
+
875
+ // Find the code block ID and cycle its language
876
+ const wrapper = target.closest("[data-code-block-id]");
877
+ const codeBlockId = wrapper?.getAttribute("data-code-block-id");
878
+
879
+ if (codeBlockId) {
880
+ const state = codeBlocks.codeBlocks.get(codeBlockId);
881
+
882
+ if (state) {
883
+ // Cycle through available formats based on current language
884
+ const currentLang = state.language || "yaml";
885
+ let nextLang: string;
886
+
887
+ if (currentLang === "json" || currentLang === "yaml") {
888
+ // Cycle: yaml -> json -> yaml (YAML/JSON only)
889
+ nextLang = currentLang === "yaml" ? "json" : "yaml";
890
+ } else if (currentLang === "text" || currentLang === "markdown") {
891
+ // Cycle: text -> markdown -> text
892
+ nextLang = currentLang === "text" ? "markdown" : "text";
893
+ } else {
894
+ // For other languages, don't cycle
895
+ nextLang = currentLang;
896
+ }
897
+
898
+ if (nextLang !== currentLang) {
899
+ codeBlocks.updateCodeBlockLanguage(codeBlockId, nextLang);
900
+ }
901
+ }
902
+ }
903
+ return;
904
+ }
905
+
906
+ if (isInCodeBlock) {
907
+ return;
908
+ }
909
+
910
+ // Handle Backspace at the start of a paragraph after a code block
911
+ if (event.key === "Backspace" && !event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey) {
912
+ if (handleBackspaceIntoCodeBlock()) {
913
+ event.preventDefault();
914
+ return;
915
+ }
916
+ }
917
+
918
+ // Handle arrow keys in tables (without modifiers for simple navigation)
919
+ if ((event.key === "ArrowUp" || event.key === "ArrowDown") &&
920
+ !event.ctrlKey && !event.altKey && !event.shiftKey && !event.metaKey) {
921
+ if (tables.isInTable()) {
922
+ // Always navigate to the same column in the adjacent row
923
+ const handled = event.key === "ArrowUp"
924
+ ? tables.navigateToCellAbove()
925
+ : tables.navigateToCellBelow();
926
+ if (handled) {
927
+ event.preventDefault();
928
+ return;
929
+ }
930
+ }
931
+ }
932
+
933
+ // Handle Enter key for code block, table, and list continuation
934
+ if (event.key === "Enter" && !event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey) {
935
+ // Check for code fence pattern first (e.g., "```javascript" -> code block)
936
+ // This triggers only on Enter, allowing user to type full language before conversion
937
+ const convertedToCodeBlock = codeBlocks.checkAndConvertCodeBlockPattern();
938
+ if (convertedToCodeBlock) {
939
+ event.preventDefault();
940
+ return;
941
+ }
942
+
943
+ // Check existing code block - Enter inserts newline, or exits after double-Enter at end
944
+ const handledByCodeBlock = codeBlocks.handleCodeBlockEnter();
945
+ if (handledByCodeBlock) {
946
+ event.preventDefault();
947
+ return;
948
+ }
949
+
950
+ // Check if in a table - Enter moves to cell below or creates new row
951
+ if (tables.isInTable()) {
952
+ event.preventDefault();
953
+ tables.handleTableEnter();
954
+ return;
955
+ }
956
+
957
+ // Then check lists
958
+ const handled = lists.handleListEnter();
959
+ if (handled) {
960
+ event.preventDefault();
961
+ return;
962
+ }
963
+ }
964
+
965
+ // Handle Tab key - always prevent default to keep focus in editor
966
+ if (event.key === "Tab" && !event.ctrlKey && !event.altKey && !event.metaKey) {
967
+ event.preventDefault();
968
+
969
+ // Check if in a table first - Tab navigates between cells
970
+ if (tables.isInTable()) {
971
+ tables.handleTableTab(event.shiftKey);
972
+ return;
973
+ }
974
+
975
+ if (event.shiftKey) {
976
+ // Shift+Tab - outdent if in list, otherwise do nothing
977
+ lists.outdentListItem();
978
+ } else {
979
+ // Tab - indent if in list, otherwise insert tab character
980
+ const handled = lists.indentListItem();
981
+ if (!handled) {
982
+ // Not in a list - insert a tab character at cursor position
983
+ insertTabCharacter();
984
+ }
985
+ }
986
+ return;
987
+ }
988
+
989
+ // Let hotkeys handle other keys - if not handled, default browser behavior occurs
990
+ hotkeys.handleKeyDown(event);
991
+ }
992
+
993
+ /**
994
+ * Handle blur events - sync immediately
995
+ */
996
+ function onBlur(): void {
997
+ sync.syncFromHtml();
998
+ }
999
+
1000
+ /**
1001
+ * Set markdown content from external source
1002
+ */
1003
+ function setMarkdown(markdown: string): void {
1004
+ sync.syncFromMarkdown(markdown);
1005
+
1006
+ // Update the contenteditable element
1007
+ nextTick(() => {
1008
+ if (contentRef.value) {
1009
+ contentRef.value.innerHTML = sync.renderedHtml.value;
1010
+ }
1011
+ });
1012
+ }
1013
+
1014
+ /**
1015
+ * Show hotkey help popover
1016
+ */
1017
+ function showHotkeyHelp(): void {
1018
+ isShowingHotkeyHelp.value = true;
1019
+ }
1020
+
1021
+ /**
1022
+ * Hide hotkey help popover
1023
+ */
1024
+ function hideHotkeyHelp(): void {
1025
+ isShowingHotkeyHelp.value = false;
1026
+ }
1027
+
1028
+ // Initialize with initial value
1029
+ if (initialValue) {
1030
+ sync.syncFromMarkdown(initialValue);
1031
+ }
1032
+
1033
+ return {
1034
+ // From sync
1035
+ renderedHtml: sync.renderedHtml,
1036
+ isInternalUpdate: sync.isInternalUpdate,
1037
+
1038
+ // State
1039
+ isShowingHotkeyHelp,
1040
+ charCount,
1041
+
1042
+ // Event handlers
1043
+ onInput,
1044
+ onKeyDown,
1045
+ onBlur,
1046
+
1047
+ // External value updates
1048
+ setMarkdown,
1049
+
1050
+ // Formatting actions
1051
+ insertHorizontalRule,
1052
+
1053
+ // Hotkey help
1054
+ showHotkeyHelp,
1055
+ hideHotkeyHelp,
1056
+ hotkeyDefinitions,
1057
+
1058
+ // Feature access
1059
+ headings,
1060
+ inlineFormatting,
1061
+ links,
1062
+ lists,
1063
+ codeBlocks,
1064
+ codeBlockManager,
1065
+ blockquotes,
1066
+ tables
1067
+ };
1068
+ }