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.
- package/dist/danx.es.js +17884 -12732
- package/dist/danx.es.js.map +1 -1
- package/dist/danx.umd.js +192 -118
- package/dist/danx.umd.js.map +1 -1
- package/dist/style.css +1 -1
- package/package.json +11 -2
- package/scripts/publish.sh +76 -0
- package/src/components/Utility/Code/CodeViewer.vue +31 -14
- package/src/components/Utility/Code/CodeViewerCollapsed.vue +2 -0
- package/src/components/Utility/Code/CodeViewerFooter.vue +1 -1
- package/src/components/Utility/Code/LanguageBadge.vue +278 -5
- package/src/components/Utility/Code/MarkdownContent.vue +160 -6
- package/src/components/Utility/Code/index.ts +3 -0
- package/src/components/Utility/Markdown/ContextMenu.vue +314 -0
- package/src/components/Utility/Markdown/HotkeyHelpPopover.vue +259 -0
- package/src/components/Utility/Markdown/LineTypeMenu.vue +226 -0
- package/src/components/Utility/Markdown/LinkPopover.vue +331 -0
- package/src/components/Utility/Markdown/MarkdownEditor.vue +228 -0
- package/src/components/Utility/Markdown/MarkdownEditorContent.vue +235 -0
- package/src/components/Utility/Markdown/MarkdownEditorFooter.vue +50 -0
- package/src/components/Utility/Markdown/TablePopover.vue +420 -0
- package/src/components/Utility/Markdown/index.ts +11 -0
- package/src/components/Utility/Markdown/types.ts +27 -0
- package/src/components/Utility/index.ts +1 -0
- package/src/composables/index.ts +1 -0
- package/src/composables/markdown/features/useBlockquotes.spec.ts +428 -0
- package/src/composables/markdown/features/useBlockquotes.ts +248 -0
- package/src/composables/markdown/features/useCodeBlockManager.ts +369 -0
- package/src/composables/markdown/features/useCodeBlocks.spec.ts +779 -0
- package/src/composables/markdown/features/useCodeBlocks.ts +774 -0
- package/src/composables/markdown/features/useContextMenu.ts +444 -0
- package/src/composables/markdown/features/useFocusTracking.ts +116 -0
- package/src/composables/markdown/features/useHeadings.spec.ts +834 -0
- package/src/composables/markdown/features/useHeadings.ts +290 -0
- package/src/composables/markdown/features/useInlineFormatting.spec.ts +705 -0
- package/src/composables/markdown/features/useInlineFormatting.ts +402 -0
- package/src/composables/markdown/features/useLineTypeMenu.ts +285 -0
- package/src/composables/markdown/features/useLinks.spec.ts +369 -0
- package/src/composables/markdown/features/useLinks.ts +374 -0
- package/src/composables/markdown/features/useLists.spec.ts +834 -0
- package/src/composables/markdown/features/useLists.ts +747 -0
- package/src/composables/markdown/features/usePopoverManager.ts +181 -0
- package/src/composables/markdown/features/useTables.spec.ts +1601 -0
- package/src/composables/markdown/features/useTables.ts +1107 -0
- package/src/composables/markdown/index.ts +16 -0
- package/src/composables/markdown/useMarkdownEditor.spec.ts +332 -0
- package/src/composables/markdown/useMarkdownEditor.ts +1068 -0
- package/src/composables/markdown/useMarkdownHotkeys.spec.ts +791 -0
- package/src/composables/markdown/useMarkdownHotkeys.ts +266 -0
- package/src/composables/markdown/useMarkdownSelection.ts +219 -0
- package/src/composables/markdown/useMarkdownSync.ts +549 -0
- package/src/composables/useCodeViewerEditor.spec.ts +655 -0
- package/src/composables/useCodeViewerEditor.ts +174 -20
- package/src/helpers/formats/index.ts +1 -1
- package/src/helpers/formats/markdown/escapeHtml.ts +15 -0
- package/src/helpers/formats/markdown/escapeSequences.ts +60 -0
- package/src/helpers/formats/markdown/htmlToMarkdown/convertHeadings.ts +41 -0
- package/src/helpers/formats/markdown/htmlToMarkdown/index.spec.ts +489 -0
- package/src/helpers/formats/markdown/htmlToMarkdown/index.ts +412 -0
- package/src/helpers/formats/markdown/index.ts +92 -0
- package/src/helpers/formats/markdown/linePatterns.spec.ts +495 -0
- package/src/helpers/formats/markdown/linePatterns.ts +172 -0
- package/src/helpers/formats/markdown/parseInline.ts +124 -0
- package/src/helpers/formats/markdown/render/index.ts +92 -0
- package/src/helpers/formats/markdown/render/renderFootnotes.ts +30 -0
- package/src/helpers/formats/markdown/render/renderList.ts +69 -0
- package/src/helpers/formats/markdown/render/renderTable.ts +38 -0
- package/src/helpers/formats/markdown/state.ts +58 -0
- package/src/helpers/formats/markdown/tokenize/extractDefinitions.ts +39 -0
- package/src/helpers/formats/markdown/tokenize/index.ts +139 -0
- package/src/helpers/formats/markdown/tokenize/parseBlockquote.ts +34 -0
- package/src/helpers/formats/markdown/tokenize/parseCodeBlock.ts +85 -0
- package/src/helpers/formats/markdown/tokenize/parseDefinitionList.ts +88 -0
- package/src/helpers/formats/markdown/tokenize/parseHeading.ts +65 -0
- package/src/helpers/formats/markdown/tokenize/parseHorizontalRule.ts +22 -0
- package/src/helpers/formats/markdown/tokenize/parseList.ts +119 -0
- package/src/helpers/formats/markdown/tokenize/parseParagraph.ts +59 -0
- package/src/helpers/formats/markdown/tokenize/parseTable.ts +70 -0
- package/src/helpers/formats/markdown/tokenize/parseTaskList.ts +47 -0
- package/src/helpers/formats/markdown/tokenize/utils.ts +25 -0
- package/src/helpers/formats/markdown/types.ts +63 -0
- package/src/styles/danx.scss +1 -0
- package/src/styles/themes/danx/markdown.scss +96 -0
- package/src/test/helpers/editorTestUtils.spec.ts +296 -0
- package/src/test/helpers/editorTestUtils.ts +253 -0
- package/src/test/helpers/index.ts +1 -0
- package/src/test/setup.test.ts +12 -0
- package/src/test/setup.ts +12 -0
- package/vitest.config.ts +19 -0
- package/src/helpers/formats/renderMarkdown.ts +0 -338
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { Ref } from "vue";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Options for useBlockquotes composable
|
|
5
|
+
*/
|
|
6
|
+
export interface UseBlockquotesOptions {
|
|
7
|
+
contentRef: Ref<HTMLElement | null>;
|
|
8
|
+
onContentChange: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Return type for useBlockquotes composable
|
|
13
|
+
*/
|
|
14
|
+
export interface UseBlockquotesReturn {
|
|
15
|
+
/** Toggle blockquote on the current block */
|
|
16
|
+
toggleBlockquote: () => void;
|
|
17
|
+
/** Check if cursor is inside a blockquote */
|
|
18
|
+
isInBlockquote: () => boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Block-level tags that can be wrapped in or unwrapped from blockquotes
|
|
23
|
+
*/
|
|
24
|
+
const BLOCK_TAGS = ["P", "H1", "H2", "H3", "H4", "H5", "H6", "DIV"];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Find the nearest block-level element containing the cursor
|
|
28
|
+
*/
|
|
29
|
+
function findCurrentBlock(node: Node | null, contentRef: HTMLElement): Element | null {
|
|
30
|
+
if (!node) return null;
|
|
31
|
+
|
|
32
|
+
let current: Node | null = node;
|
|
33
|
+
while (current && current !== contentRef) {
|
|
34
|
+
if (current.nodeType === Node.ELEMENT_NODE) {
|
|
35
|
+
const element = current as Element;
|
|
36
|
+
if (BLOCK_TAGS.includes(element.tagName) || element.tagName === "BLOCKQUOTE") {
|
|
37
|
+
return element;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
current = current.parentNode;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Find the blockquote ancestor if one exists
|
|
48
|
+
*/
|
|
49
|
+
function findBlockquoteAncestor(node: Node | null, contentRef: HTMLElement): HTMLQuoteElement | null {
|
|
50
|
+
if (!node) return null;
|
|
51
|
+
|
|
52
|
+
let current: Node | null = node;
|
|
53
|
+
while (current && current !== contentRef) {
|
|
54
|
+
if (current.nodeType === Node.ELEMENT_NODE && (current as Element).tagName === "BLOCKQUOTE") {
|
|
55
|
+
return current as HTMLQuoteElement;
|
|
56
|
+
}
|
|
57
|
+
current = current.parentNode;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get the cursor offset within a block element's text content
|
|
65
|
+
*/
|
|
66
|
+
function getCursorOffset(element: HTMLElement): number {
|
|
67
|
+
const selection = window.getSelection();
|
|
68
|
+
if (!selection || !selection.rangeCount) return 0;
|
|
69
|
+
|
|
70
|
+
const range = selection.getRangeAt(0);
|
|
71
|
+
const preCaretRange = document.createRange();
|
|
72
|
+
preCaretRange.selectNodeContents(element);
|
|
73
|
+
preCaretRange.setEnd(range.startContainer, range.startOffset);
|
|
74
|
+
|
|
75
|
+
return preCaretRange.toString().length;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Set cursor to a specific offset within an element's text content
|
|
80
|
+
*/
|
|
81
|
+
function setCursorOffset(element: HTMLElement, targetOffset: number): void {
|
|
82
|
+
const selection = window.getSelection();
|
|
83
|
+
if (!selection) return;
|
|
84
|
+
|
|
85
|
+
let currentOffset = 0;
|
|
86
|
+
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
87
|
+
let node = walker.nextNode();
|
|
88
|
+
|
|
89
|
+
while (node) {
|
|
90
|
+
const nodeLength = node.textContent?.length || 0;
|
|
91
|
+
if (currentOffset + nodeLength >= targetOffset) {
|
|
92
|
+
const range = document.createRange();
|
|
93
|
+
range.setStart(node, targetOffset - currentOffset);
|
|
94
|
+
range.collapse(true);
|
|
95
|
+
selection.removeAllRanges();
|
|
96
|
+
selection.addRange(range);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
currentOffset += nodeLength;
|
|
100
|
+
node = walker.nextNode();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// If offset not found, place cursor at end
|
|
104
|
+
const range = document.createRange();
|
|
105
|
+
range.selectNodeContents(element);
|
|
106
|
+
range.collapse(false);
|
|
107
|
+
selection.removeAllRanges();
|
|
108
|
+
selection.addRange(range);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Composable for blockquote operations in markdown editor
|
|
113
|
+
*/
|
|
114
|
+
export function useBlockquotes(options: UseBlockquotesOptions): UseBlockquotesReturn {
|
|
115
|
+
const { contentRef, onContentChange } = options;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Check if the cursor is currently inside a blockquote
|
|
119
|
+
*/
|
|
120
|
+
function isInBlockquote(): boolean {
|
|
121
|
+
if (!contentRef.value) return false;
|
|
122
|
+
|
|
123
|
+
const selection = window.getSelection();
|
|
124
|
+
if (!selection || !selection.rangeCount) return false;
|
|
125
|
+
|
|
126
|
+
const range = selection.getRangeAt(0);
|
|
127
|
+
return findBlockquoteAncestor(range.startContainer, contentRef.value) !== null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Toggle blockquote on the current block
|
|
132
|
+
*
|
|
133
|
+
* Behavior:
|
|
134
|
+
* - If cursor is inside a blockquote: unwrap the block from the blockquote
|
|
135
|
+
* - If cursor is not in a blockquote: wrap the current block in a blockquote
|
|
136
|
+
* - Preserves cursor position after transformation
|
|
137
|
+
*/
|
|
138
|
+
function toggleBlockquote(): void {
|
|
139
|
+
if (!contentRef.value) return;
|
|
140
|
+
|
|
141
|
+
const selection = window.getSelection();
|
|
142
|
+
if (!selection || !selection.rangeCount) return;
|
|
143
|
+
|
|
144
|
+
const range = selection.getRangeAt(0);
|
|
145
|
+
|
|
146
|
+
// Check if selection is within our content area
|
|
147
|
+
if (!contentRef.value.contains(range.startContainer)) return;
|
|
148
|
+
|
|
149
|
+
const blockquote = findBlockquoteAncestor(range.startContainer, contentRef.value);
|
|
150
|
+
|
|
151
|
+
if (blockquote) {
|
|
152
|
+
unwrapBlockquote(blockquote);
|
|
153
|
+
} else {
|
|
154
|
+
wrapInBlockquote();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
onContentChange();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Unwrap content from a blockquote
|
|
162
|
+
*/
|
|
163
|
+
function unwrapBlockquote(blockquote: HTMLQuoteElement): void {
|
|
164
|
+
const parent = blockquote.parentNode;
|
|
165
|
+
if (!parent) return;
|
|
166
|
+
|
|
167
|
+
// Save cursor position
|
|
168
|
+
const selection = window.getSelection();
|
|
169
|
+
let cursorOffset = 0;
|
|
170
|
+
let targetBlock: Element | null = null;
|
|
171
|
+
|
|
172
|
+
if (selection && selection.rangeCount > 0) {
|
|
173
|
+
const range = selection.getRangeAt(0);
|
|
174
|
+
const currentBlock = findCurrentBlock(range.startContainer, contentRef.value!);
|
|
175
|
+
|
|
176
|
+
// If the current block is inside the blockquote, get its offset
|
|
177
|
+
if (currentBlock && blockquote.contains(currentBlock)) {
|
|
178
|
+
targetBlock = currentBlock;
|
|
179
|
+
cursorOffset = getCursorOffset(currentBlock as HTMLElement);
|
|
180
|
+
} else if (currentBlock === blockquote) {
|
|
181
|
+
// Cursor is directly in blockquote text node
|
|
182
|
+
cursorOffset = getCursorOffset(blockquote);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Move all children out of the blockquote
|
|
187
|
+
const children = Array.from(blockquote.childNodes);
|
|
188
|
+
let firstMovedElement: Element | null = null;
|
|
189
|
+
|
|
190
|
+
for (const child of children) {
|
|
191
|
+
const insertedNode = parent.insertBefore(child, blockquote);
|
|
192
|
+
if (!firstMovedElement && insertedNode.nodeType === Node.ELEMENT_NODE) {
|
|
193
|
+
firstMovedElement = insertedNode as Element;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Remove the empty blockquote
|
|
198
|
+
parent.removeChild(blockquote);
|
|
199
|
+
|
|
200
|
+
// Restore cursor position
|
|
201
|
+
if (targetBlock && parent.contains(targetBlock)) {
|
|
202
|
+
setCursorOffset(targetBlock as HTMLElement, cursorOffset);
|
|
203
|
+
} else if (firstMovedElement) {
|
|
204
|
+
setCursorOffset(firstMovedElement as HTMLElement, cursorOffset);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Wrap the current block in a blockquote
|
|
210
|
+
*/
|
|
211
|
+
function wrapInBlockquote(): void {
|
|
212
|
+
if (!contentRef.value) return;
|
|
213
|
+
|
|
214
|
+
const selection = window.getSelection();
|
|
215
|
+
if (!selection || !selection.rangeCount) return;
|
|
216
|
+
|
|
217
|
+
const range = selection.getRangeAt(0);
|
|
218
|
+
const currentBlock = findCurrentBlock(range.startContainer, contentRef.value);
|
|
219
|
+
|
|
220
|
+
if (!currentBlock) return;
|
|
221
|
+
|
|
222
|
+
// Don't wrap if already in a blockquote
|
|
223
|
+
if (currentBlock.tagName === "BLOCKQUOTE") return;
|
|
224
|
+
|
|
225
|
+
// Save cursor position
|
|
226
|
+
const cursorOffset = getCursorOffset(currentBlock as HTMLElement);
|
|
227
|
+
|
|
228
|
+
// Create blockquote and wrap the block
|
|
229
|
+
const blockquote = document.createElement("blockquote");
|
|
230
|
+
|
|
231
|
+
// Insert blockquote before the current block
|
|
232
|
+
const parent = currentBlock.parentNode;
|
|
233
|
+
if (!parent) return;
|
|
234
|
+
|
|
235
|
+
parent.insertBefore(blockquote, currentBlock);
|
|
236
|
+
|
|
237
|
+
// Move the block into the blockquote
|
|
238
|
+
blockquote.appendChild(currentBlock);
|
|
239
|
+
|
|
240
|
+
// Restore cursor position
|
|
241
|
+
setCursorOffset(currentBlock as HTMLElement, cursorOffset);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
toggleBlockquote,
|
|
246
|
+
isInBlockquote
|
|
247
|
+
};
|
|
248
|
+
}
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { Quasar } from "quasar";
|
|
2
|
+
import { App, computed, createApp, h, nextTick, onUnmounted, ref, Ref, watch } from "vue";
|
|
3
|
+
import CodeViewer from "../../../components/Utility/Code/CodeViewer.vue";
|
|
4
|
+
import { CodeBlockState } from "./useCodeBlocks";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Options for useCodeBlockManager composable
|
|
8
|
+
*/
|
|
9
|
+
export interface UseCodeBlockManagerOptions {
|
|
10
|
+
/** Reference to the contenteditable container */
|
|
11
|
+
contentRef: Ref<HTMLElement | null>;
|
|
12
|
+
/** Reactive map of code block states */
|
|
13
|
+
codeBlocks: Map<string, CodeBlockState>;
|
|
14
|
+
/** Callback to update code block content */
|
|
15
|
+
updateCodeBlockContent: (id: string, content: string) => void;
|
|
16
|
+
/** Callback to update code block language */
|
|
17
|
+
updateCodeBlockLanguage: (id: string, language: string) => void;
|
|
18
|
+
/** Callback when a code block requests to exit (e.g., double-Enter at end) */
|
|
19
|
+
onCodeBlockExit?: (id: string) => void;
|
|
20
|
+
/** Callback when a code block requests to be deleted (e.g., Backspace/Delete on empty) */
|
|
21
|
+
onCodeBlockDelete?: (id: string) => void;
|
|
22
|
+
/** Callback when a code block is mounted */
|
|
23
|
+
onCodeBlockMounted?: (id: string, wrapper: HTMLElement) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Return type for useCodeBlockManager composable
|
|
28
|
+
*/
|
|
29
|
+
export interface UseCodeBlockManagerReturn {
|
|
30
|
+
/** Mount CodeViewer instances for all unmounted code blocks */
|
|
31
|
+
mountCodeViewers: () => void;
|
|
32
|
+
/** Unmount a specific CodeViewer instance */
|
|
33
|
+
unmountCodeViewer: (id: string) => void;
|
|
34
|
+
/** Unmount all CodeViewer instances */
|
|
35
|
+
unmountAllCodeViewers: () => void;
|
|
36
|
+
/** Get mounted instance count */
|
|
37
|
+
getMountedCount: () => number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Mounted CodeViewer instance tracking
|
|
42
|
+
*/
|
|
43
|
+
interface MountedInstance {
|
|
44
|
+
app: App;
|
|
45
|
+
mountPoint: HTMLElement;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Map language aliases to CodeViewer format
|
|
50
|
+
*/
|
|
51
|
+
function mapLanguageToFormat(language: string): string {
|
|
52
|
+
const formatMap: Record<string, string> = {
|
|
53
|
+
js: "javascript",
|
|
54
|
+
ts: "typescript",
|
|
55
|
+
py: "python",
|
|
56
|
+
rb: "ruby",
|
|
57
|
+
yml: "yaml",
|
|
58
|
+
md: "markdown",
|
|
59
|
+
sh: "bash",
|
|
60
|
+
shell: "bash"
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return formatMap[language] || language || "text";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Composable for managing CodeViewer instances within the markdown editor.
|
|
68
|
+
* Handles mounting/unmounting Vue apps for code block "islands".
|
|
69
|
+
*/
|
|
70
|
+
export function useCodeBlockManager(options: UseCodeBlockManagerOptions): UseCodeBlockManagerReturn {
|
|
71
|
+
const { contentRef, codeBlocks, updateCodeBlockContent, updateCodeBlockLanguage, onCodeBlockExit, onCodeBlockDelete, onCodeBlockMounted } = options;
|
|
72
|
+
|
|
73
|
+
// Track mounted instances by code block ID
|
|
74
|
+
const mountedInstances = new Map<string, MountedInstance>();
|
|
75
|
+
|
|
76
|
+
// Track watchers for cleanup
|
|
77
|
+
const mountedWatchers = new Map<string, () => void>();
|
|
78
|
+
|
|
79
|
+
// MutationObserver to watch for new code block wrappers
|
|
80
|
+
let observer: MutationObserver | null = null;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Create and mount a CodeViewer instance for a code block
|
|
84
|
+
*/
|
|
85
|
+
function mountCodeViewer(wrapper: HTMLElement): void {
|
|
86
|
+
const id = wrapper.getAttribute("data-code-block-id");
|
|
87
|
+
if (!id) return;
|
|
88
|
+
|
|
89
|
+
// Skip if already mounted
|
|
90
|
+
if (mountedInstances.has(id)) return;
|
|
91
|
+
|
|
92
|
+
const mountPoint = wrapper.querySelector(".code-viewer-mount-point") as HTMLElement;
|
|
93
|
+
if (!mountPoint) return;
|
|
94
|
+
|
|
95
|
+
// Get state from the codeBlocks map
|
|
96
|
+
const state = codeBlocks.get(id);
|
|
97
|
+
|
|
98
|
+
// If no state in map, try to get from data attributes (initial load)
|
|
99
|
+
const initialContent = state?.content ?? mountPoint.getAttribute("data-content") ?? "";
|
|
100
|
+
const initialLanguage = state?.language ?? mountPoint.getAttribute("data-language") ?? "";
|
|
101
|
+
|
|
102
|
+
// Ensure state exists in the map
|
|
103
|
+
if (!state) {
|
|
104
|
+
codeBlocks.set(id, {
|
|
105
|
+
id,
|
|
106
|
+
content: initialContent,
|
|
107
|
+
language: initialLanguage
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Create reactive refs for the CodeViewer props
|
|
112
|
+
// These will be updated by watchers when the codeBlocks map changes
|
|
113
|
+
const reactiveContent = ref(initialContent);
|
|
114
|
+
const reactiveLanguage = ref(initialLanguage);
|
|
115
|
+
|
|
116
|
+
// Set up watchers to track changes to this code block's state
|
|
117
|
+
// We need separate watchers for content and language to ensure Vue properly
|
|
118
|
+
// tracks the reactive properties accessed within each getter function
|
|
119
|
+
const stopContentWatcher = watch(
|
|
120
|
+
() => codeBlocks.get(id)?.content,
|
|
121
|
+
(newContent) => {
|
|
122
|
+
if (newContent !== undefined) {
|
|
123
|
+
reactiveContent.value = newContent;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const stopLanguageWatcher = watch(
|
|
129
|
+
() => codeBlocks.get(id)?.language,
|
|
130
|
+
(newLanguage) => {
|
|
131
|
+
if (newLanguage !== undefined) {
|
|
132
|
+
reactiveLanguage.value = newLanguage;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// Combined cleanup function for both watchers
|
|
138
|
+
const stopWatcher = () => {
|
|
139
|
+
stopContentWatcher();
|
|
140
|
+
stopLanguageWatcher();
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// Store the watcher cleanup function
|
|
144
|
+
mountedWatchers.set(id, stopWatcher);
|
|
145
|
+
|
|
146
|
+
// Create Vue app for CodeViewer
|
|
147
|
+
const app = createApp({
|
|
148
|
+
setup() {
|
|
149
|
+
// Handle model value updates
|
|
150
|
+
const onUpdateModelValue = (value: object | string | null) => {
|
|
151
|
+
const content = typeof value === "string" ? value : JSON.stringify(value);
|
|
152
|
+
updateCodeBlockContent(id, content);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// Handle format/language updates
|
|
156
|
+
const onUpdateFormat = (format: string) => {
|
|
157
|
+
updateCodeBlockLanguage(id, format);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// Handle exit (double-Enter at end of code block)
|
|
161
|
+
const onExit = () => {
|
|
162
|
+
if (onCodeBlockExit) {
|
|
163
|
+
onCodeBlockExit(id);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// Handle delete (Backspace/Delete on empty code block)
|
|
168
|
+
const onDelete = () => {
|
|
169
|
+
if (onCodeBlockDelete) {
|
|
170
|
+
onCodeBlockDelete(id);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// Create a computed for the format to ensure proper reactivity tracking
|
|
175
|
+
// The render function needs a computed that Vue can track for re-renders
|
|
176
|
+
const computedFormat = computed(() => mapLanguageToFormat(reactiveLanguage.value));
|
|
177
|
+
|
|
178
|
+
return () => h(CodeViewer, {
|
|
179
|
+
modelValue: reactiveContent.value,
|
|
180
|
+
format: computedFormat.value,
|
|
181
|
+
canEdit: true,
|
|
182
|
+
editable: true,
|
|
183
|
+
allowAnyLanguage: true,
|
|
184
|
+
class: "code-block-island",
|
|
185
|
+
"onUpdate:modelValue": onUpdateModelValue,
|
|
186
|
+
"onUpdate:format": onUpdateFormat,
|
|
187
|
+
onExit,
|
|
188
|
+
onDelete
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Install Quasar on the dynamically created app
|
|
194
|
+
// This is required because createApp() doesn't inherit plugins from the parent app
|
|
195
|
+
app.use(Quasar, { plugins: {} });
|
|
196
|
+
|
|
197
|
+
// Clear mount point content (remove data attributes div structure)
|
|
198
|
+
mountPoint.innerHTML = "";
|
|
199
|
+
|
|
200
|
+
// Mount the app
|
|
201
|
+
app.mount(mountPoint);
|
|
202
|
+
|
|
203
|
+
// Track the instance
|
|
204
|
+
mountedInstances.set(id, { app, mountPoint });
|
|
205
|
+
|
|
206
|
+
// Notify that the code block was mounted (after nextTick to ensure DOM is ready)
|
|
207
|
+
if (onCodeBlockMounted) {
|
|
208
|
+
nextTick(() => {
|
|
209
|
+
onCodeBlockMounted(id, wrapper);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Unmount a specific CodeViewer instance
|
|
216
|
+
*/
|
|
217
|
+
function unmountCodeViewer(id: string): void {
|
|
218
|
+
// Clean up watcher
|
|
219
|
+
const stopWatcher = mountedWatchers.get(id);
|
|
220
|
+
if (stopWatcher) {
|
|
221
|
+
stopWatcher();
|
|
222
|
+
mountedWatchers.delete(id);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Unmount Vue app
|
|
226
|
+
const instance = mountedInstances.get(id);
|
|
227
|
+
if (instance) {
|
|
228
|
+
instance.app.unmount();
|
|
229
|
+
mountedInstances.delete(id);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Unmount all CodeViewer instances
|
|
235
|
+
*/
|
|
236
|
+
function unmountAllCodeViewers(): void {
|
|
237
|
+
// Clean up all watchers
|
|
238
|
+
for (const [id, stopWatcher] of mountedWatchers) {
|
|
239
|
+
stopWatcher();
|
|
240
|
+
mountedWatchers.delete(id);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Unmount all Vue apps
|
|
244
|
+
for (const [id, instance] of mountedInstances) {
|
|
245
|
+
instance.app.unmount();
|
|
246
|
+
mountedInstances.delete(id);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Mount CodeViewer instances for all unmounted code blocks
|
|
252
|
+
*/
|
|
253
|
+
function mountCodeViewers(): void {
|
|
254
|
+
if (!contentRef.value) return;
|
|
255
|
+
|
|
256
|
+
const wrappers = contentRef.value.querySelectorAll("[data-code-block-id]");
|
|
257
|
+
wrappers.forEach((wrapper) => {
|
|
258
|
+
mountCodeViewer(wrapper as HTMLElement);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Get mounted instance count
|
|
264
|
+
*/
|
|
265
|
+
function getMountedCount(): number {
|
|
266
|
+
return mountedInstances.size;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Handle mutations to the content element
|
|
271
|
+
*/
|
|
272
|
+
function handleMutations(mutations: MutationRecord[]): void {
|
|
273
|
+
for (const mutation of mutations) {
|
|
274
|
+
// Check for added nodes that are code block wrappers
|
|
275
|
+
for (const node of Array.from(mutation.addedNodes)) {
|
|
276
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
277
|
+
const element = node as HTMLElement;
|
|
278
|
+
|
|
279
|
+
// Check if the added node is a code block wrapper
|
|
280
|
+
if (element.hasAttribute && element.hasAttribute("data-code-block-id")) {
|
|
281
|
+
nextTick(() => mountCodeViewer(element));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Check children for code block wrappers
|
|
285
|
+
const childWrappers = element.querySelectorAll?.("[data-code-block-id]");
|
|
286
|
+
if (childWrappers && childWrappers.length > 0) {
|
|
287
|
+
childWrappers.forEach((wrapper) => {
|
|
288
|
+
nextTick(() => mountCodeViewer(wrapper as HTMLElement));
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Check for removed nodes that were code block wrappers
|
|
295
|
+
for (const node of Array.from(mutation.removedNodes)) {
|
|
296
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
297
|
+
const element = node as HTMLElement;
|
|
298
|
+
|
|
299
|
+
// Check if the removed node is a code block wrapper
|
|
300
|
+
const id = element.getAttribute?.("data-code-block-id");
|
|
301
|
+
if (id) {
|
|
302
|
+
unmountCodeViewer(id);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Check children for code block wrappers
|
|
306
|
+
const childWrappers = element.querySelectorAll?.("[data-code-block-id]");
|
|
307
|
+
if (childWrappers) {
|
|
308
|
+
childWrappers.forEach((wrapper) => {
|
|
309
|
+
const childId = wrapper.getAttribute("data-code-block-id");
|
|
310
|
+
if (childId) {
|
|
311
|
+
unmountCodeViewer(childId);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Start observing for DOM changes
|
|
322
|
+
*/
|
|
323
|
+
function startObserver(): void {
|
|
324
|
+
if (!contentRef.value || observer) return;
|
|
325
|
+
|
|
326
|
+
observer = new MutationObserver(handleMutations);
|
|
327
|
+
observer.observe(contentRef.value, {
|
|
328
|
+
childList: true,
|
|
329
|
+
subtree: true
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Stop observing DOM changes
|
|
335
|
+
*/
|
|
336
|
+
function stopObserver(): void {
|
|
337
|
+
if (observer) {
|
|
338
|
+
observer.disconnect();
|
|
339
|
+
observer = null;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Watch for contentRef changes to set up/tear down observer
|
|
344
|
+
watch(contentRef, (newRef, oldRef) => {
|
|
345
|
+
if (oldRef && !newRef) {
|
|
346
|
+
// Content ref was removed - clean up
|
|
347
|
+
stopObserver();
|
|
348
|
+
unmountAllCodeViewers();
|
|
349
|
+
} else if (newRef && !oldRef) {
|
|
350
|
+
// Content ref was added - set up
|
|
351
|
+
startObserver();
|
|
352
|
+
// Mount any existing code blocks
|
|
353
|
+
nextTick(() => mountCodeViewers());
|
|
354
|
+
}
|
|
355
|
+
}, { immediate: true });
|
|
356
|
+
|
|
357
|
+
// Clean up on unmount
|
|
358
|
+
onUnmounted(() => {
|
|
359
|
+
stopObserver();
|
|
360
|
+
unmountAllCodeViewers();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
mountCodeViewers,
|
|
365
|
+
unmountCodeViewer,
|
|
366
|
+
unmountAllCodeViewers,
|
|
367
|
+
getMountedCount
|
|
368
|
+
};
|
|
369
|
+
}
|