quasar-ui-danx 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +8 -0
- package/dist/danx.es.js +16119 -10641
- package/dist/danx.es.js.map +1 -1
- package/dist/danx.umd.js +202 -123
- package/dist/danx.umd.js.map +1 -1
- package/dist/style.css +1 -1
- package/package.json +8 -1
- package/src/components/Utility/Buttons/ActionButton.vue +15 -5
- package/src/components/Utility/Code/CodeViewer.vue +41 -16
- package/src/components/Utility/Code/CodeViewerCollapsed.vue +2 -0
- package/src/components/Utility/Code/CodeViewerFooter.vue +3 -1
- package/src/components/Utility/Code/LanguageBadge.vue +278 -5
- package/src/components/Utility/Code/MarkdownContent.vue +31 -163
- 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 +233 -0
- package/src/components/Utility/Markdown/MarkdownEditorContent.vue +296 -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/Widgets/LabelPillWidget.vue +20 -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 +805 -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 +388 -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 +1077 -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/useCodeFormat.ts +17 -10
- package/src/composables/useCodeViewerEditor.spec.ts +655 -0
- package/src/composables/useCodeViewerEditor.ts +174 -20
- package/src/helpers/formats/highlightCSS.ts +236 -0
- package/src/helpers/formats/highlightHTML.ts +483 -0
- package/src/helpers/formats/highlightJavaScript.ts +346 -0
- package/src/helpers/formats/highlightSyntax.ts +15 -4
- package/src/helpers/formats/index.ts +3 -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 +425 -0
- package/src/helpers/formats/markdown/index.ts +7 -0
- package/src/helpers/formats/markdown/linePatterns.spec.ts +498 -0
- package/src/helpers/formats/markdown/linePatterns.ts +172 -0
- package/src/styles/danx.scss +3 -3
- package/src/styles/index.scss +5 -5
- package/src/styles/themes/danx/code.scss +257 -1
- package/src/styles/themes/danx/index.scss +10 -10
- package/src/styles/themes/danx/markdown.scss +59 -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/highlighters.test.ts +153 -0
- package/src/test/setup.test.ts +12 -0
- package/src/test/setup.ts +12 -0
- package/src/types/widgets.d.ts +2 -2
- package/vite.config.js +5 -1
- package/vitest.config.ts +19 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { Ref } from "vue";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Options for useInlineFormatting composable
|
|
5
|
+
*/
|
|
6
|
+
export interface UseInlineFormattingOptions {
|
|
7
|
+
contentRef: Ref<HTMLElement | null>;
|
|
8
|
+
onContentChange: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Return type for useInlineFormatting composable
|
|
13
|
+
*/
|
|
14
|
+
export interface UseInlineFormattingReturn {
|
|
15
|
+
/** Toggle bold formatting on selection */
|
|
16
|
+
toggleBold: () => void;
|
|
17
|
+
/** Toggle italic formatting on selection */
|
|
18
|
+
toggleItalic: () => void;
|
|
19
|
+
/** Toggle strikethrough formatting on selection */
|
|
20
|
+
toggleStrikethrough: () => void;
|
|
21
|
+
/** Toggle inline code formatting on selection */
|
|
22
|
+
toggleInlineCode: () => void;
|
|
23
|
+
/** Toggle highlight formatting on selection */
|
|
24
|
+
toggleHighlight: () => void;
|
|
25
|
+
/** Toggle underline formatting on selection */
|
|
26
|
+
toggleUnderline: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Inline formatting tag mappings
|
|
31
|
+
*/
|
|
32
|
+
const FORMAT_TAGS = {
|
|
33
|
+
bold: { tag: "STRONG", fallback: "B" },
|
|
34
|
+
italic: { tag: "EM", fallback: "I" },
|
|
35
|
+
strikethrough: { tag: "DEL", fallback: "S" },
|
|
36
|
+
code: { tag: "CODE", fallback: null },
|
|
37
|
+
highlight: { tag: "MARK", fallback: null },
|
|
38
|
+
underline: { tag: "U", fallback: null }
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
type FormatType = keyof typeof FORMAT_TAGS;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check if a node or its ancestors have a specific formatting tag
|
|
45
|
+
*/
|
|
46
|
+
function hasFormatting(node: Node | null, formatType: FormatType): Element | null {
|
|
47
|
+
const { tag, fallback } = FORMAT_TAGS[formatType];
|
|
48
|
+
|
|
49
|
+
let current: Node | null = node;
|
|
50
|
+
while (current && current.nodeType !== Node.DOCUMENT_NODE) {
|
|
51
|
+
if (current.nodeType === Node.ELEMENT_NODE) {
|
|
52
|
+
const element = current as Element;
|
|
53
|
+
const tagName = element.tagName.toUpperCase();
|
|
54
|
+
if (tagName === tag || (fallback && tagName === fallback)) {
|
|
55
|
+
return element;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
current = current.parentNode;
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Composable for inline formatting operations in markdown editor
|
|
65
|
+
*/
|
|
66
|
+
export function useInlineFormatting(options: UseInlineFormattingOptions): UseInlineFormattingReturn {
|
|
67
|
+
const { contentRef, onContentChange } = options;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Apply or remove inline formatting to the current selection
|
|
71
|
+
*
|
|
72
|
+
* Behavior:
|
|
73
|
+
* - With selection that matches entire formatted element: remove formatting
|
|
74
|
+
* - With selection inside formatted element: remove formatting from selection only
|
|
75
|
+
* - With selection (no existing format): wrap selection with formatting
|
|
76
|
+
* - No selection, cursor inside formatted text: move cursor after formatted element
|
|
77
|
+
* - No selection, cursor outside formatted text: insert formatted placeholder
|
|
78
|
+
*/
|
|
79
|
+
function toggleFormat(formatType: FormatType): void {
|
|
80
|
+
if (!contentRef.value) return;
|
|
81
|
+
|
|
82
|
+
const selection = window.getSelection();
|
|
83
|
+
if (!selection || selection.rangeCount === 0) return;
|
|
84
|
+
|
|
85
|
+
const range = selection.getRangeAt(0);
|
|
86
|
+
|
|
87
|
+
// Check if selection is within our content area
|
|
88
|
+
if (!contentRef.value.contains(range.commonAncestorContainer)) return;
|
|
89
|
+
|
|
90
|
+
const { tag } = FORMAT_TAGS[formatType];
|
|
91
|
+
|
|
92
|
+
// Check if the selection/cursor is inside formatted text
|
|
93
|
+
const existingFormat = hasFormatting(range.commonAncestorContainer, formatType);
|
|
94
|
+
|
|
95
|
+
if (!range.collapsed) {
|
|
96
|
+
// There's a selection
|
|
97
|
+
if (existingFormat && isSelectionEntireElement(range, existingFormat)) {
|
|
98
|
+
// Selection matches the entire formatted element - remove formatting
|
|
99
|
+
removeFormatting(existingFormat);
|
|
100
|
+
} else if (existingFormat) {
|
|
101
|
+
// Selection is partially inside formatted element - remove format from selection
|
|
102
|
+
unwrapSelectionFromFormat(range, existingFormat, formatType);
|
|
103
|
+
} else {
|
|
104
|
+
// Selection has no formatting - wrap it
|
|
105
|
+
wrapSelection(range, tag.toLowerCase());
|
|
106
|
+
}
|
|
107
|
+
} else {
|
|
108
|
+
// No selection (cursor only)
|
|
109
|
+
if (existingFormat) {
|
|
110
|
+
// Cursor is inside formatted text - move cursor after the formatted element
|
|
111
|
+
moveCursorAfterElement(existingFormat);
|
|
112
|
+
} else {
|
|
113
|
+
// Cursor is in unformatted area - insert formatted placeholder
|
|
114
|
+
insertFormattedPlaceholder(range, tag.toLowerCase(), formatType);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
onContentChange();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Check if selection encompasses the entire element's content
|
|
123
|
+
*/
|
|
124
|
+
function isSelectionEntireElement(range: Range, element: Element): boolean {
|
|
125
|
+
return range.toString() === element.textContent;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Move cursor to position immediately after an element by inserting a
|
|
130
|
+
* zero-width space to break out of the formatting context.
|
|
131
|
+
* The ZWS is cleaned up during HTML→markdown conversion.
|
|
132
|
+
*/
|
|
133
|
+
function moveCursorAfterElement(element: Element): void {
|
|
134
|
+
const selection = window.getSelection();
|
|
135
|
+
if (!selection) return;
|
|
136
|
+
|
|
137
|
+
// Insert a zero-width space after the element to break out of formatting
|
|
138
|
+
const zws = document.createTextNode("\u200B");
|
|
139
|
+
element.parentNode?.insertBefore(zws, element.nextSibling);
|
|
140
|
+
|
|
141
|
+
// Position cursor after the zero-width space
|
|
142
|
+
const range = document.createRange();
|
|
143
|
+
range.setStart(zws, 1);
|
|
144
|
+
range.collapse(true);
|
|
145
|
+
|
|
146
|
+
selection.removeAllRanges();
|
|
147
|
+
selection.addRange(range);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Remove formatting from just the selected portion within a formatted element
|
|
152
|
+
*/
|
|
153
|
+
function unwrapSelectionFromFormat(range: Range, formatElement: Element, formatType: FormatType): void {
|
|
154
|
+
const { tag } = FORMAT_TAGS[formatType];
|
|
155
|
+
const tagLower = tag.toLowerCase();
|
|
156
|
+
|
|
157
|
+
// Get the selected text
|
|
158
|
+
const selectedText = range.toString();
|
|
159
|
+
const fullText = formatElement.textContent || "";
|
|
160
|
+
|
|
161
|
+
// Find where the selection is within the formatted element
|
|
162
|
+
const beforeText = fullText.substring(0, fullText.indexOf(selectedText));
|
|
163
|
+
const afterText = fullText.substring(fullText.indexOf(selectedText) + selectedText.length);
|
|
164
|
+
|
|
165
|
+
const parent = formatElement.parentNode;
|
|
166
|
+
if (!parent) return;
|
|
167
|
+
|
|
168
|
+
// Create new structure: [before formatted] [unformatted selection] [after formatted]
|
|
169
|
+
const fragment = document.createDocumentFragment();
|
|
170
|
+
|
|
171
|
+
if (beforeText) {
|
|
172
|
+
const beforeElement = document.createElement(tagLower);
|
|
173
|
+
beforeElement.textContent = beforeText;
|
|
174
|
+
fragment.appendChild(beforeElement);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// The unformatted selected text
|
|
178
|
+
const unformattedText = document.createTextNode(selectedText);
|
|
179
|
+
fragment.appendChild(unformattedText);
|
|
180
|
+
|
|
181
|
+
if (afterText) {
|
|
182
|
+
const afterElement = document.createElement(tagLower);
|
|
183
|
+
afterElement.textContent = afterText;
|
|
184
|
+
fragment.appendChild(afterElement);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Replace the original formatted element
|
|
188
|
+
parent.replaceChild(fragment, formatElement);
|
|
189
|
+
|
|
190
|
+
// Select the unformatted text
|
|
191
|
+
const newRange = document.createRange();
|
|
192
|
+
newRange.selectNodeContents(unformattedText);
|
|
193
|
+
const selection = window.getSelection();
|
|
194
|
+
if (selection) {
|
|
195
|
+
selection.removeAllRanges();
|
|
196
|
+
selection.addRange(newRange);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Remove formatting from an element, keeping its content
|
|
202
|
+
*/
|
|
203
|
+
function removeFormatting(element: Element): void {
|
|
204
|
+
const parent = element.parentNode;
|
|
205
|
+
if (!parent) return;
|
|
206
|
+
|
|
207
|
+
// Move all children out of the formatted element
|
|
208
|
+
while (element.firstChild) {
|
|
209
|
+
parent.insertBefore(element.firstChild, element);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Remove the empty formatting element
|
|
213
|
+
parent.removeChild(element);
|
|
214
|
+
|
|
215
|
+
// Normalize to merge adjacent text nodes
|
|
216
|
+
parent.normalize();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Unwrap all existing formatting elements of the same type within a node tree.
|
|
221
|
+
* This prevents nested formatting like <mark><mark>text</mark></mark>.
|
|
222
|
+
*/
|
|
223
|
+
function unwrapExistingFormat(container: Node, tagName: string): void {
|
|
224
|
+
// Handle both Element and DocumentFragment (from extractContents)
|
|
225
|
+
if (container.nodeType === Node.ELEMENT_NODE || container.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
|
|
226
|
+
// Use a temporary div to enable querySelectorAll on DocumentFragment
|
|
227
|
+
const wrapper = document.createElement("div");
|
|
228
|
+
while (container.firstChild) {
|
|
229
|
+
wrapper.appendChild(container.firstChild);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Find all matching elements
|
|
233
|
+
const matchingElements = Array.from(wrapper.querySelectorAll(tagName));
|
|
234
|
+
|
|
235
|
+
// Unwrap each matching element (replace with its children)
|
|
236
|
+
for (const el of matchingElements) {
|
|
237
|
+
const parent = el.parentNode;
|
|
238
|
+
if (parent) {
|
|
239
|
+
while (el.firstChild) {
|
|
240
|
+
parent.insertBefore(el.firstChild, el);
|
|
241
|
+
}
|
|
242
|
+
parent.removeChild(el);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Move content back to original container
|
|
247
|
+
while (wrapper.firstChild) {
|
|
248
|
+
container.appendChild(wrapper.firstChild);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Remove empty formatting elements from a parent node
|
|
255
|
+
*/
|
|
256
|
+
function removeEmptyElements(parent: Node, tagName: string): void {
|
|
257
|
+
if (parent.nodeType === Node.ELEMENT_NODE) {
|
|
258
|
+
const element = parent as Element;
|
|
259
|
+
// Find all matching elements that have no meaningful content
|
|
260
|
+
const emptyElements = Array.from(element.querySelectorAll(tagName)).filter(
|
|
261
|
+
el => !el.textContent?.trim()
|
|
262
|
+
);
|
|
263
|
+
for (const el of emptyElements) {
|
|
264
|
+
el.parentNode?.removeChild(el);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Wrap the current selection with a formatting tag
|
|
271
|
+
*/
|
|
272
|
+
function wrapSelection(range: Range, tagName: string): void {
|
|
273
|
+
// Create the formatting element
|
|
274
|
+
const formatElement = document.createElement(tagName);
|
|
275
|
+
|
|
276
|
+
// Extract the selected content and wrap it
|
|
277
|
+
const contents = range.extractContents();
|
|
278
|
+
|
|
279
|
+
// Unwrap any existing formatting of the same type to prevent nesting
|
|
280
|
+
unwrapExistingFormat(contents, tagName);
|
|
281
|
+
|
|
282
|
+
formatElement.appendChild(contents);
|
|
283
|
+
|
|
284
|
+
// Insert the wrapped content
|
|
285
|
+
range.insertNode(formatElement);
|
|
286
|
+
|
|
287
|
+
// Clean up empty elements left behind by extractContents
|
|
288
|
+
const parentBlock = formatElement.parentElement;
|
|
289
|
+
if (parentBlock) {
|
|
290
|
+
removeEmptyElements(parentBlock, tagName);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Normalize to merge adjacent text nodes
|
|
294
|
+
formatElement.normalize();
|
|
295
|
+
|
|
296
|
+
// Select the newly formatted content
|
|
297
|
+
const newRange = document.createRange();
|
|
298
|
+
newRange.selectNodeContents(formatElement);
|
|
299
|
+
|
|
300
|
+
const selection = window.getSelection();
|
|
301
|
+
if (selection) {
|
|
302
|
+
selection.removeAllRanges();
|
|
303
|
+
selection.addRange(newRange);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Insert a formatted placeholder when there's no selection
|
|
309
|
+
*/
|
|
310
|
+
function insertFormattedPlaceholder(range: Range, tagName: string, formatType: FormatType): void {
|
|
311
|
+
// Create the formatting element with placeholder text
|
|
312
|
+
const formatElement = document.createElement(tagName);
|
|
313
|
+
const placeholderText = getPlaceholderText(formatType);
|
|
314
|
+
formatElement.textContent = placeholderText;
|
|
315
|
+
|
|
316
|
+
// Insert at cursor position
|
|
317
|
+
range.insertNode(formatElement);
|
|
318
|
+
|
|
319
|
+
// Select the placeholder text so user can type over it
|
|
320
|
+
const newRange = document.createRange();
|
|
321
|
+
newRange.selectNodeContents(formatElement);
|
|
322
|
+
|
|
323
|
+
const selection = window.getSelection();
|
|
324
|
+
if (selection) {
|
|
325
|
+
selection.removeAllRanges();
|
|
326
|
+
selection.addRange(newRange);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Get placeholder text for a format type
|
|
332
|
+
*/
|
|
333
|
+
function getPlaceholderText(formatType: FormatType): string {
|
|
334
|
+
switch (formatType) {
|
|
335
|
+
case "bold":
|
|
336
|
+
return "bold text";
|
|
337
|
+
case "italic":
|
|
338
|
+
return "italic text";
|
|
339
|
+
case "strikethrough":
|
|
340
|
+
return "strikethrough text";
|
|
341
|
+
case "code":
|
|
342
|
+
return "code";
|
|
343
|
+
case "highlight":
|
|
344
|
+
return "highlighted text";
|
|
345
|
+
case "underline":
|
|
346
|
+
return "underlined text";
|
|
347
|
+
default:
|
|
348
|
+
return "text";
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Toggle bold formatting (Ctrl+B)
|
|
354
|
+
*/
|
|
355
|
+
function toggleBold(): void {
|
|
356
|
+
toggleFormat("bold");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Toggle italic formatting (Ctrl+I)
|
|
361
|
+
*/
|
|
362
|
+
function toggleItalic(): void {
|
|
363
|
+
toggleFormat("italic");
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Toggle strikethrough formatting (Ctrl+Shift+S)
|
|
368
|
+
*/
|
|
369
|
+
function toggleStrikethrough(): void {
|
|
370
|
+
toggleFormat("strikethrough");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Toggle inline code formatting (Ctrl+E)
|
|
375
|
+
*/
|
|
376
|
+
function toggleInlineCode(): void {
|
|
377
|
+
toggleFormat("code");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Toggle highlight formatting (Ctrl+Shift+H)
|
|
382
|
+
*/
|
|
383
|
+
function toggleHighlight(): void {
|
|
384
|
+
toggleFormat("highlight");
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Toggle underline formatting (Ctrl+U)
|
|
389
|
+
*/
|
|
390
|
+
function toggleUnderline(): void {
|
|
391
|
+
toggleFormat("underline");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
toggleBold,
|
|
396
|
+
toggleItalic,
|
|
397
|
+
toggleStrikethrough,
|
|
398
|
+
toggleInlineCode,
|
|
399
|
+
toggleHighlight,
|
|
400
|
+
toggleUnderline
|
|
401
|
+
};
|
|
402
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { computed, ComputedRef, onMounted, onUnmounted, Ref, ref, watch } from "vue";
|
|
2
|
+
import { LineType } from "../../../components/Utility/Markdown/types";
|
|
3
|
+
import { UseMarkdownEditorReturn } from "../useMarkdownEditor";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Options for useLineTypeMenu composable
|
|
7
|
+
*/
|
|
8
|
+
export interface UseLineTypeMenuOptions {
|
|
9
|
+
contentRef: Ref<HTMLElement | null>;
|
|
10
|
+
editor: UseMarkdownEditorReturn;
|
|
11
|
+
isEditorFocused: Ref<boolean>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Return type for useLineTypeMenu composable
|
|
16
|
+
*/
|
|
17
|
+
export interface UseLineTypeMenuReturn {
|
|
18
|
+
currentLineType: ComputedRef<LineType>;
|
|
19
|
+
menuStyle: ComputedRef<{ top: string; opacity: number; pointerEvents: string }>;
|
|
20
|
+
onLineTypeChange: (type: LineType) => void;
|
|
21
|
+
updatePositionAndState: () => void;
|
|
22
|
+
setupListeners: () => void;
|
|
23
|
+
cleanupListeners: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Map LineType to heading level (single source of truth for bidirectional mapping)
|
|
27
|
+
// Note: ul and ol are handled separately, not as heading levels
|
|
28
|
+
const LINE_TYPE_TO_LEVEL: Record<string, number> = {
|
|
29
|
+
paragraph: 0,
|
|
30
|
+
h1: 1,
|
|
31
|
+
h2: 2,
|
|
32
|
+
h3: 3,
|
|
33
|
+
h4: 4,
|
|
34
|
+
h5: 5,
|
|
35
|
+
h6: 6
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Derive the inverse mapping from LINE_TYPE_TO_LEVEL
|
|
39
|
+
const LEVEL_TO_LINE_TYPE = Object.fromEntries(
|
|
40
|
+
Object.entries(LINE_TYPE_TO_LEVEL).map(([type, level]) => [level, type as LineType])
|
|
41
|
+
) as Record<number, LineType>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Composable for managing the floating line type menu in the markdown editor.
|
|
45
|
+
* Handles line type detection, menu positioning, and line type changes.
|
|
46
|
+
*/
|
|
47
|
+
export function useLineTypeMenu(options: UseLineTypeMenuOptions): UseLineTypeMenuReturn {
|
|
48
|
+
const { contentRef, editor, isEditorFocused } = options;
|
|
49
|
+
|
|
50
|
+
// Track current heading level for LineTypeMenu
|
|
51
|
+
const currentHeadingLevel = ref(0);
|
|
52
|
+
|
|
53
|
+
// Track the current block element's top position for floating menu
|
|
54
|
+
const currentBlockTop = ref(0);
|
|
55
|
+
|
|
56
|
+
// Track current list type for LineTypeMenu
|
|
57
|
+
const currentListType = ref<"ul" | "ol" | null>(null);
|
|
58
|
+
|
|
59
|
+
// Track if we're in a code block
|
|
60
|
+
const isInCodeBlock = ref(false);
|
|
61
|
+
|
|
62
|
+
// Computed current line type from heading level, list type, or code block
|
|
63
|
+
const currentLineType = computed<LineType>(() => {
|
|
64
|
+
// If we're in a code block, return "code"
|
|
65
|
+
if (isInCodeBlock.value) {
|
|
66
|
+
return "code";
|
|
67
|
+
}
|
|
68
|
+
// If we're in a list, return the list type
|
|
69
|
+
if (currentListType.value) {
|
|
70
|
+
return currentListType.value;
|
|
71
|
+
}
|
|
72
|
+
// Otherwise, return heading type based on level
|
|
73
|
+
const level = currentHeadingLevel.value;
|
|
74
|
+
return LEVEL_TO_LINE_TYPE[level] ?? "paragraph";
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Computed style for the floating line type menu
|
|
78
|
+
const menuStyle = computed(() => {
|
|
79
|
+
return {
|
|
80
|
+
top: `${currentBlockTop.value}px`,
|
|
81
|
+
opacity: isEditorFocused.value ? 1 : 0,
|
|
82
|
+
pointerEvents: isEditorFocused.value ? "auto" : "none"
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Find the block element containing the current selection
|
|
88
|
+
*/
|
|
89
|
+
function findCurrentBlockElement(): HTMLElement | null {
|
|
90
|
+
const selection = window.getSelection();
|
|
91
|
+
if (!selection || selection.rangeCount === 0) return null;
|
|
92
|
+
|
|
93
|
+
let node: Node | null = selection.anchorNode;
|
|
94
|
+
|
|
95
|
+
// Walk up to find a block element (P, H1-H6, DIV, etc.)
|
|
96
|
+
while (node && node !== contentRef.value) {
|
|
97
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
98
|
+
const element = node as HTMLElement;
|
|
99
|
+
const tagName = element.tagName.toUpperCase();
|
|
100
|
+
if (["P", "H1", "H2", "H3", "H4", "H5", "H6", "DIV", "BLOCKQUOTE", "PRE", "UL", "OL", "LI"].includes(tagName)) {
|
|
101
|
+
return element;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
node = node.parentNode;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Update the floating menu position based on current block
|
|
112
|
+
*/
|
|
113
|
+
function updateMenuPosition(): void {
|
|
114
|
+
const contentEl = contentRef.value;
|
|
115
|
+
if (!contentEl) return;
|
|
116
|
+
|
|
117
|
+
const blockElement = findCurrentBlockElement();
|
|
118
|
+
if (!blockElement) {
|
|
119
|
+
// If no block found, position at top
|
|
120
|
+
currentBlockTop.value = 0;
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Get the block's position relative to the content container
|
|
125
|
+
// We need to account for the content's scroll position
|
|
126
|
+
const contentRect = contentEl.getBoundingClientRect();
|
|
127
|
+
const blockRect = blockElement.getBoundingClientRect();
|
|
128
|
+
|
|
129
|
+
// Calculate top position relative to the content container, accounting for scroll
|
|
130
|
+
const relativeTop = blockRect.top - contentRect.top + contentEl.scrollTop;
|
|
131
|
+
currentBlockTop.value = relativeTop;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Update current heading level, list type, code block state, and menu position when selection changes
|
|
136
|
+
*/
|
|
137
|
+
function updatePositionAndState(): void {
|
|
138
|
+
const level = editor.headings.getCurrentHeadingLevel();
|
|
139
|
+
currentHeadingLevel.value = level;
|
|
140
|
+
// Also check if we're in a list
|
|
141
|
+
currentListType.value = editor.lists.getCurrentListType();
|
|
142
|
+
// Also check if we're in a code block
|
|
143
|
+
isInCodeBlock.value = editor.codeBlocks.isInCodeBlock();
|
|
144
|
+
updateMenuPosition();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Handle line type change from menu
|
|
149
|
+
*/
|
|
150
|
+
function onLineTypeChange(type: LineType): void {
|
|
151
|
+
// Handle code block type
|
|
152
|
+
if (type === "code") {
|
|
153
|
+
// If already in a code block, do nothing (or toggle off if that's desired)
|
|
154
|
+
if (editor.codeBlocks.isInCodeBlock()) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// If currently in a list, first convert the list item to paragraph
|
|
159
|
+
const listType = editor.lists.getCurrentListType();
|
|
160
|
+
if (listType) {
|
|
161
|
+
editor.lists.convertCurrentListItemToParagraph();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Now toggle to code block
|
|
165
|
+
editor.codeBlocks.toggleCodeBlock();
|
|
166
|
+
isInCodeBlock.value = true;
|
|
167
|
+
currentListType.value = null;
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Handle list types
|
|
172
|
+
if (type === "ul") {
|
|
173
|
+
// If in code block, first convert to paragraph
|
|
174
|
+
if (editor.codeBlocks.isInCodeBlock()) {
|
|
175
|
+
editor.codeBlocks.toggleCodeBlock();
|
|
176
|
+
}
|
|
177
|
+
editor.lists.toggleUnorderedList();
|
|
178
|
+
currentListType.value = editor.lists.getCurrentListType();
|
|
179
|
+
isInCodeBlock.value = false;
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (type === "ol") {
|
|
183
|
+
// If in code block, first convert to paragraph
|
|
184
|
+
if (editor.codeBlocks.isInCodeBlock()) {
|
|
185
|
+
editor.codeBlocks.toggleCodeBlock();
|
|
186
|
+
}
|
|
187
|
+
editor.lists.toggleOrderedList();
|
|
188
|
+
currentListType.value = editor.lists.getCurrentListType();
|
|
189
|
+
isInCodeBlock.value = false;
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Handle heading/paragraph types
|
|
194
|
+
const level = LINE_TYPE_TO_LEVEL[type];
|
|
195
|
+
if (level !== undefined) {
|
|
196
|
+
// If currently in a code block, first convert to paragraph
|
|
197
|
+
if (editor.codeBlocks.isInCodeBlock()) {
|
|
198
|
+
editor.codeBlocks.toggleCodeBlock();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// If currently in a list, first convert the list item to paragraph
|
|
202
|
+
const listType = editor.lists.getCurrentListType();
|
|
203
|
+
if (listType) {
|
|
204
|
+
editor.lists.convertCurrentListItemToParagraph();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Now apply the heading level (0 = paragraph, 1-6 = heading)
|
|
208
|
+
// Only set heading if level > 0 (paragraph is already the result of list conversion)
|
|
209
|
+
if (level > 0) {
|
|
210
|
+
editor.headings.setHeadingLevel(level as 1 | 2 | 3 | 4 | 5 | 6);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Update the tracked level immediately
|
|
214
|
+
currentHeadingLevel.value = level;
|
|
215
|
+
currentListType.value = null;
|
|
216
|
+
isInCodeBlock.value = false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Track which element has listeners attached
|
|
221
|
+
let boundContentEl: HTMLElement | null = null;
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Handle focus in event
|
|
225
|
+
*/
|
|
226
|
+
function handleFocusIn(event: FocusEvent): void {
|
|
227
|
+
const contentEl = contentRef.value;
|
|
228
|
+
if (contentEl && contentEl.contains(event.target as Node)) {
|
|
229
|
+
// Note: isEditorFocused is managed externally, but we update menu position
|
|
230
|
+
updateMenuPosition();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Setup/cleanup focus and scroll listeners on content element
|
|
236
|
+
*/
|
|
237
|
+
function setupContentListeners(el: HTMLElement | null): void {
|
|
238
|
+
// Cleanup previous listeners if element changed
|
|
239
|
+
if (boundContentEl && boundContentEl !== el) {
|
|
240
|
+
boundContentEl.removeEventListener("focusin", handleFocusIn);
|
|
241
|
+
boundContentEl.removeEventListener("scroll", updateMenuPosition);
|
|
242
|
+
boundContentEl = null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Setup new listeners
|
|
246
|
+
if (el && el !== boundContentEl) {
|
|
247
|
+
el.addEventListener("focusin", handleFocusIn);
|
|
248
|
+
el.addEventListener("scroll", updateMenuPosition);
|
|
249
|
+
boundContentEl = el;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Setup all listeners (call from component's onMounted)
|
|
255
|
+
*/
|
|
256
|
+
function setupListeners(): void {
|
|
257
|
+
// Setup content listeners
|
|
258
|
+
setupContentListeners(contentRef.value);
|
|
259
|
+
|
|
260
|
+
// Watch for content element to become available
|
|
261
|
+
watch(contentRef, (newEl) => {
|
|
262
|
+
setupContentListeners(newEl);
|
|
263
|
+
}, { immediate: true });
|
|
264
|
+
|
|
265
|
+
// Listen for selection changes
|
|
266
|
+
document.addEventListener("selectionchange", updatePositionAndState);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Cleanup all listeners (call from component's onUnmounted)
|
|
271
|
+
*/
|
|
272
|
+
function cleanupListeners(): void {
|
|
273
|
+
document.removeEventListener("selectionchange", updatePositionAndState);
|
|
274
|
+
setupContentListeners(null);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
currentLineType,
|
|
279
|
+
menuStyle,
|
|
280
|
+
onLineTypeChange,
|
|
281
|
+
updatePositionAndState,
|
|
282
|
+
setupListeners,
|
|
283
|
+
cleanupListeners
|
|
284
|
+
};
|
|
285
|
+
}
|