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