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
|
@@ -9,29 +9,17 @@
|
|
|
9
9
|
/>
|
|
10
10
|
|
|
11
11
|
<!-- Code blocks with syntax highlighting -->
|
|
12
|
-
<
|
|
12
|
+
<CodeViewer
|
|
13
13
|
v-else-if="token.type === 'code_block'"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
/>
|
|
24
|
-
<LanguageBadge
|
|
25
|
-
v-else-if="token.language"
|
|
26
|
-
:format="token.language"
|
|
27
|
-
:available-formats="[]"
|
|
28
|
-
:toggleable="false"
|
|
29
|
-
/>
|
|
30
|
-
<pre><code
|
|
31
|
-
:class="'language-' + getCodeBlockFormat(index, token.language)"
|
|
32
|
-
v-html="highlightCodeBlock(index, token.content, token.language)"
|
|
33
|
-
></code></pre>
|
|
34
|
-
</div>
|
|
14
|
+
:model-value="token.content"
|
|
15
|
+
:format="normalizeLanguage(token.language)"
|
|
16
|
+
:default-code-format="defaultCodeFormat"
|
|
17
|
+
:can-edit="false"
|
|
18
|
+
:collapsible="false"
|
|
19
|
+
hide-footer
|
|
20
|
+
allow-any-language
|
|
21
|
+
class="markdown-code-block"
|
|
22
|
+
/>
|
|
35
23
|
|
|
36
24
|
<!-- Blockquotes (recursive) -->
|
|
37
25
|
<blockquote
|
|
@@ -186,12 +174,10 @@
|
|
|
186
174
|
</template>
|
|
187
175
|
|
|
188
176
|
<script setup lang="ts">
|
|
189
|
-
import { computed
|
|
190
|
-
import { parse as parseYAML, stringify as stringifyYAML } from "yaml";
|
|
177
|
+
import { computed } from "vue";
|
|
191
178
|
import { tokenizeBlocks, parseInline, renderMarkdown, getFootnotes, resetParserState } from "../../../helpers/formats/markdown";
|
|
192
179
|
import type { BlockToken, ListItem } from "../../../helpers/formats/markdown";
|
|
193
|
-
import
|
|
194
|
-
import LanguageBadge from "./LanguageBadge.vue";
|
|
180
|
+
import CodeViewer from "./CodeViewer.vue";
|
|
195
181
|
|
|
196
182
|
export interface MarkdownContentProps {
|
|
197
183
|
content: string;
|
|
@@ -202,10 +188,21 @@ const props = withDefaults(defineProps<MarkdownContentProps>(), {
|
|
|
202
188
|
content: ""
|
|
203
189
|
});
|
|
204
190
|
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const
|
|
191
|
+
// Normalize language aliases to standard names
|
|
192
|
+
function normalizeLanguage(lang?: string): string {
|
|
193
|
+
if (!lang) return "text";
|
|
194
|
+
const aliases: Record<string, string> = {
|
|
195
|
+
js: "javascript",
|
|
196
|
+
ts: "typescript",
|
|
197
|
+
py: "python",
|
|
198
|
+
rb: "ruby",
|
|
199
|
+
yml: "yaml",
|
|
200
|
+
md: "markdown",
|
|
201
|
+
sh: "bash",
|
|
202
|
+
shell: "bash"
|
|
203
|
+
};
|
|
204
|
+
return aliases[lang.toLowerCase()] || lang.toLowerCase();
|
|
205
|
+
}
|
|
209
206
|
|
|
210
207
|
// Tokenize the markdown content
|
|
211
208
|
const tokens = computed<BlockToken[]>(() => {
|
|
@@ -234,124 +231,6 @@ const sortedFootnotes = computed(() => {
|
|
|
234
231
|
.map(([id, fn]) => ({ id, content: fn.content, index: fn.index }));
|
|
235
232
|
});
|
|
236
233
|
|
|
237
|
-
// Check if a language is toggleable (json or yaml)
|
|
238
|
-
function isToggleableLanguage(language: string): boolean {
|
|
239
|
-
if (!language) return false;
|
|
240
|
-
const lang = language.toLowerCase();
|
|
241
|
-
return lang === "json" || lang === "yaml";
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// Get the current format for a code block (respecting user toggle, then default override, then original)
|
|
245
|
-
function getCodeBlockFormat(index: number, originalLanguage: string): string {
|
|
246
|
-
// If user has toggled this block, use their choice
|
|
247
|
-
if (codeBlockFormats[index]) {
|
|
248
|
-
return codeBlockFormats[index];
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// If a default is set and this is a toggleable language, use the default
|
|
252
|
-
const lang = originalLanguage?.toLowerCase();
|
|
253
|
-
if (props.defaultCodeFormat && (lang === "json" || lang === "yaml")) {
|
|
254
|
-
return props.defaultCodeFormat;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// Otherwise use the original language
|
|
258
|
-
return lang || "text";
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// Get converted content for a code block (handles initial conversion for defaultCodeFormat)
|
|
262
|
-
function getConvertedContent(index: number, originalContent: string, originalLang: string): string {
|
|
263
|
-
const format = getCodeBlockFormat(index, originalLang);
|
|
264
|
-
|
|
265
|
-
// If format matches original, no conversion needed
|
|
266
|
-
if (format === originalLang || !isToggleableLanguage(originalLang)) {
|
|
267
|
-
return originalContent;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// Convert from original to target format
|
|
271
|
-
try {
|
|
272
|
-
let parsed: unknown;
|
|
273
|
-
|
|
274
|
-
if (originalLang === "json") {
|
|
275
|
-
parsed = JSON.parse(originalContent);
|
|
276
|
-
} else if (originalLang === "yaml") {
|
|
277
|
-
parsed = parseYAML(originalContent);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
if (parsed !== undefined) {
|
|
281
|
-
if (format === "json") {
|
|
282
|
-
return JSON.stringify(parsed, null, 2);
|
|
283
|
-
} else if (format === "yaml") {
|
|
284
|
-
return stringifyYAML(parsed as object);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
} catch {
|
|
288
|
-
// Conversion failed, return original
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
return originalContent;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// Set format for a code block (converts content to the new format)
|
|
295
|
-
function setCodeBlockFormat(index: number, newFormat: string) {
|
|
296
|
-
const token = tokens.value[index];
|
|
297
|
-
if (token?.type !== "code_block") return;
|
|
298
|
-
|
|
299
|
-
const originalLang = token.language?.toLowerCase() || "json";
|
|
300
|
-
const current = getCodeBlockFormat(index, originalLang);
|
|
301
|
-
|
|
302
|
-
// No change needed if already in target format
|
|
303
|
-
if (current === newFormat) return;
|
|
304
|
-
|
|
305
|
-
// Convert the content
|
|
306
|
-
try {
|
|
307
|
-
// Use the currently displayed content (which may already be converted due to defaultCodeFormat)
|
|
308
|
-
const sourceContent = convertedContent[index] || getConvertedContent(index, token.content, originalLang);
|
|
309
|
-
let parsed: unknown;
|
|
310
|
-
|
|
311
|
-
// Parse from current format
|
|
312
|
-
if (current === "json") {
|
|
313
|
-
parsed = JSON.parse(sourceContent);
|
|
314
|
-
} else {
|
|
315
|
-
parsed = parseYAML(sourceContent);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// Convert to new format
|
|
319
|
-
if (newFormat === "json") {
|
|
320
|
-
convertedContent[index] = JSON.stringify(parsed, null, 2);
|
|
321
|
-
} else {
|
|
322
|
-
convertedContent[index] = stringifyYAML(parsed as object);
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
codeBlockFormats[index] = newFormat;
|
|
326
|
-
} catch {
|
|
327
|
-
// If conversion fails, just set the format without converting
|
|
328
|
-
codeBlockFormats[index] = newFormat;
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// Highlight code block content based on format
|
|
333
|
-
function highlightCodeBlock(index: number, originalContent: string, originalLanguage: string): string {
|
|
334
|
-
const format = getCodeBlockFormat(index, originalLanguage);
|
|
335
|
-
const originalLang = originalLanguage?.toLowerCase() || "text";
|
|
336
|
-
|
|
337
|
-
// Get the content (converted if needed, or from cache if user toggled)
|
|
338
|
-
const content = convertedContent[index] || getConvertedContent(index, originalContent, originalLang);
|
|
339
|
-
|
|
340
|
-
// Apply syntax highlighting
|
|
341
|
-
switch (format) {
|
|
342
|
-
case "json":
|
|
343
|
-
return highlightJSON(content);
|
|
344
|
-
case "yaml":
|
|
345
|
-
return highlightYAML(content);
|
|
346
|
-
default:
|
|
347
|
-
// For other languages, just escape HTML
|
|
348
|
-
return content
|
|
349
|
-
.replace(/&/g, "&")
|
|
350
|
-
.replace(/</g, "<")
|
|
351
|
-
.replace(/>/g, ">");
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
234
|
// Parse inline markdown (bold, italic, links, etc.)
|
|
356
235
|
function parseInlineContent(text: string): string {
|
|
357
236
|
return parseInline(text, true);
|
|
@@ -383,23 +262,12 @@ function renderBlockquote(content: string): string {
|
|
|
383
262
|
</script>
|
|
384
263
|
|
|
385
264
|
<style lang="scss">
|
|
386
|
-
.
|
|
387
|
-
position: relative;
|
|
265
|
+
.markdown-code-block {
|
|
388
266
|
margin: 1em 0;
|
|
389
267
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
padding: 1em;
|
|
394
|
-
border-radius: 6px;
|
|
395
|
-
overflow-x: auto;
|
|
396
|
-
|
|
397
|
-
code {
|
|
398
|
-
background: transparent;
|
|
399
|
-
padding: 0;
|
|
400
|
-
font-size: 0.875em;
|
|
401
|
-
font-family: 'Fira Code', 'Monaco', monospace;
|
|
402
|
-
}
|
|
268
|
+
// Ensure auto-height instead of 100%
|
|
269
|
+
&.dx-code-viewer {
|
|
270
|
+
height: auto;
|
|
403
271
|
}
|
|
404
272
|
}
|
|
405
273
|
</style>
|
|
@@ -3,3 +3,6 @@ export { default as CodeViewerCollapsed } from "./CodeViewerCollapsed.vue";
|
|
|
3
3
|
export { default as CodeViewerFooter } from "./CodeViewerFooter.vue";
|
|
4
4
|
export { default as LanguageBadge } from "./LanguageBadge.vue";
|
|
5
5
|
export { default as MarkdownContent } from "./MarkdownContent.vue";
|
|
6
|
+
|
|
7
|
+
// Re-export Markdown editor components
|
|
8
|
+
export * from "../Markdown";
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
class="dx-context-menu-overlay"
|
|
4
|
+
@click.self="onClose"
|
|
5
|
+
@keydown.escape="onClose"
|
|
6
|
+
>
|
|
7
|
+
<div
|
|
8
|
+
ref="menuRef"
|
|
9
|
+
class="dx-context-menu"
|
|
10
|
+
:style="menuStyle"
|
|
11
|
+
>
|
|
12
|
+
<template v-for="(item, itemIndex) in items" :key="item.id">
|
|
13
|
+
<!-- Divider -->
|
|
14
|
+
<div v-if="item.divider" class="context-menu-divider" />
|
|
15
|
+
|
|
16
|
+
<!-- Regular menu item or submenu trigger -->
|
|
17
|
+
<template v-else>
|
|
18
|
+
<div
|
|
19
|
+
class="context-menu-item-wrapper"
|
|
20
|
+
@mouseenter="handleItemHover(item, itemIndex)"
|
|
21
|
+
@mouseleave="handleItemLeave"
|
|
22
|
+
>
|
|
23
|
+
<button
|
|
24
|
+
class="context-menu-item"
|
|
25
|
+
:class="{ disabled: item.disabled, 'has-children': item.children?.length }"
|
|
26
|
+
type="button"
|
|
27
|
+
:disabled="item.disabled"
|
|
28
|
+
@click="onItemClick(item)"
|
|
29
|
+
>
|
|
30
|
+
<span class="item-label">{{ item.label }}</span>
|
|
31
|
+
<span v-if="item.shortcut && !item.children" class="item-shortcut">{{ item.shortcut }}</span>
|
|
32
|
+
<span v-if="item.children?.length" class="item-chevron">▸</span>
|
|
33
|
+
</button>
|
|
34
|
+
|
|
35
|
+
<!-- Nested submenu -->
|
|
36
|
+
<div
|
|
37
|
+
v-if="item.children?.length && activeSubmenuId === item.id"
|
|
38
|
+
ref="submenuRefs"
|
|
39
|
+
class="dx-context-submenu"
|
|
40
|
+
:class="{ 'open-left': submenuOpenLeft }"
|
|
41
|
+
:data-item-id="item.id"
|
|
42
|
+
@mouseenter="handleSubmenuEnter"
|
|
43
|
+
@mouseleave="handleSubmenuLeave"
|
|
44
|
+
>
|
|
45
|
+
<template v-for="child in item.children" :key="child.id">
|
|
46
|
+
<!-- Child divider -->
|
|
47
|
+
<div v-if="child.divider" class="context-menu-divider" />
|
|
48
|
+
|
|
49
|
+
<!-- Child item -->
|
|
50
|
+
<button
|
|
51
|
+
v-else
|
|
52
|
+
class="context-menu-item"
|
|
53
|
+
:class="{ disabled: child.disabled }"
|
|
54
|
+
type="button"
|
|
55
|
+
:disabled="child.disabled"
|
|
56
|
+
@click="onItemClick(child)"
|
|
57
|
+
>
|
|
58
|
+
<span class="item-label">{{ child.label }}</span>
|
|
59
|
+
<span v-if="child.shortcut" class="item-shortcut">{{ child.shortcut }}</span>
|
|
60
|
+
</button>
|
|
61
|
+
</template>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
</template>
|
|
65
|
+
</template>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
</template>
|
|
69
|
+
|
|
70
|
+
<script setup lang="ts">
|
|
71
|
+
import { computed, onMounted, onUnmounted, ref } from "vue";
|
|
72
|
+
import type { ContextMenuItem } from "./types";
|
|
73
|
+
import type { PopoverPosition } from "@/composables/markdown";
|
|
74
|
+
|
|
75
|
+
export interface ContextMenuProps {
|
|
76
|
+
position: PopoverPosition;
|
|
77
|
+
items: ContextMenuItem[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const props = defineProps<ContextMenuProps>();
|
|
81
|
+
|
|
82
|
+
const emit = defineEmits<{
|
|
83
|
+
close: [];
|
|
84
|
+
action: [item: ContextMenuItem];
|
|
85
|
+
}>();
|
|
86
|
+
|
|
87
|
+
const menuRef = ref<HTMLElement | null>(null);
|
|
88
|
+
const activeSubmenuId = ref<string | null>(null);
|
|
89
|
+
const submenuOpenLeft = ref(false);
|
|
90
|
+
let hoverTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
91
|
+
|
|
92
|
+
// Calculate menu position with viewport boundary detection
|
|
93
|
+
const menuStyle = computed(() => {
|
|
94
|
+
const menuHeight = 400; // Approximate max height for nested menus
|
|
95
|
+
const menuWidth = 320; // Match CSS max-width
|
|
96
|
+
const padding = 10;
|
|
97
|
+
|
|
98
|
+
let top = props.position.y;
|
|
99
|
+
let left = props.position.x;
|
|
100
|
+
|
|
101
|
+
// Check if menu would extend below viewport
|
|
102
|
+
if (top + menuHeight > window.innerHeight - padding) {
|
|
103
|
+
// Position above the cursor
|
|
104
|
+
top = Math.max(padding, props.position.y - menuHeight);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Ensure menu doesn't go off left edge
|
|
108
|
+
if (left < padding) {
|
|
109
|
+
left = padding;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Ensure menu doesn't go off right edge
|
|
113
|
+
if (left + menuWidth > window.innerWidth - padding) {
|
|
114
|
+
left = window.innerWidth - menuWidth - padding;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Determine if submenus should open to the left
|
|
118
|
+
// (if menu is positioned near right edge, submenus should open left)
|
|
119
|
+
submenuOpenLeft.value = left + menuWidth + menuWidth > window.innerWidth - padding;
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
top: `${top}px`,
|
|
123
|
+
left: `${left}px`
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
function handleItemHover(item: ContextMenuItem, _index: number): void {
|
|
128
|
+
// Clear any pending timeout
|
|
129
|
+
if (hoverTimeout) {
|
|
130
|
+
clearTimeout(hoverTimeout);
|
|
131
|
+
hoverTimeout = null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// If item has children, show submenu after a small delay
|
|
135
|
+
if (item.children?.length) {
|
|
136
|
+
hoverTimeout = setTimeout(() => {
|
|
137
|
+
activeSubmenuId.value = item.id;
|
|
138
|
+
}, 100);
|
|
139
|
+
} else {
|
|
140
|
+
// Immediately hide submenu for non-parent items
|
|
141
|
+
activeSubmenuId.value = null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function handleItemLeave(): void {
|
|
146
|
+
// Clear pending timeout
|
|
147
|
+
if (hoverTimeout) {
|
|
148
|
+
clearTimeout(hoverTimeout);
|
|
149
|
+
hoverTimeout = null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Set a timeout to close submenu (will be cancelled if mouse enters submenu)
|
|
153
|
+
hoverTimeout = setTimeout(() => {
|
|
154
|
+
activeSubmenuId.value = null;
|
|
155
|
+
}, 150);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function handleSubmenuEnter(): void {
|
|
159
|
+
// Cancel any pending close timeout when entering the submenu
|
|
160
|
+
if (hoverTimeout) {
|
|
161
|
+
clearTimeout(hoverTimeout);
|
|
162
|
+
hoverTimeout = null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function handleSubmenuLeave(): void {
|
|
167
|
+
// Start timeout to close submenu when leaving
|
|
168
|
+
hoverTimeout = setTimeout(() => {
|
|
169
|
+
activeSubmenuId.value = null;
|
|
170
|
+
}, 150);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function onItemClick(item: ContextMenuItem): void {
|
|
174
|
+
if (item.disabled) return;
|
|
175
|
+
|
|
176
|
+
// If item has children, don't close - just toggle submenu
|
|
177
|
+
if (item.children?.length) {
|
|
178
|
+
activeSubmenuId.value = activeSubmenuId.value === item.id ? null : item.id;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Execute action if available
|
|
183
|
+
if (item.action) {
|
|
184
|
+
emit("action", item);
|
|
185
|
+
item.action();
|
|
186
|
+
}
|
|
187
|
+
emit("close");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function onClose(): void {
|
|
191
|
+
emit("close");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Handle Escape key at document level
|
|
195
|
+
function handleDocumentKeydown(event: KeyboardEvent): void {
|
|
196
|
+
if (event.key === "Escape") {
|
|
197
|
+
onClose();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
onMounted(() => {
|
|
202
|
+
document.addEventListener("keydown", handleDocumentKeydown);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
onUnmounted(() => {
|
|
206
|
+
document.removeEventListener("keydown", handleDocumentKeydown);
|
|
207
|
+
if (hoverTimeout) {
|
|
208
|
+
clearTimeout(hoverTimeout);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
</script>
|
|
212
|
+
|
|
213
|
+
<style lang="scss">
|
|
214
|
+
.dx-context-menu-overlay {
|
|
215
|
+
position: fixed;
|
|
216
|
+
inset: 0;
|
|
217
|
+
z-index: 1000;
|
|
218
|
+
// Transparent overlay - no visual background
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.dx-context-menu {
|
|
222
|
+
position: fixed;
|
|
223
|
+
background: #2d2d2d;
|
|
224
|
+
border: 1px solid #404040;
|
|
225
|
+
border-radius: 0.375rem;
|
|
226
|
+
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4);
|
|
227
|
+
min-width: 200px;
|
|
228
|
+
max-width: 320px;
|
|
229
|
+
overflow: visible;
|
|
230
|
+
padding: 0.25rem 0;
|
|
231
|
+
|
|
232
|
+
.context-menu-divider {
|
|
233
|
+
height: 1px;
|
|
234
|
+
background: #404040;
|
|
235
|
+
margin: 0.25rem 0;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.context-menu-item-wrapper {
|
|
239
|
+
position: relative;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.context-menu-item {
|
|
243
|
+
display: flex;
|
|
244
|
+
align-items: center;
|
|
245
|
+
justify-content: space-between;
|
|
246
|
+
width: 100%;
|
|
247
|
+
padding: 0.5rem 0.75rem;
|
|
248
|
+
background: transparent;
|
|
249
|
+
border: none;
|
|
250
|
+
color: #d4d4d4;
|
|
251
|
+
font-size: 0.875rem;
|
|
252
|
+
text-align: left;
|
|
253
|
+
cursor: pointer;
|
|
254
|
+
transition: background-color 0.15s ease;
|
|
255
|
+
|
|
256
|
+
&:hover:not(.disabled) {
|
|
257
|
+
background: rgba(255, 255, 255, 0.1);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
&.disabled {
|
|
261
|
+
color: #6b7280;
|
|
262
|
+
cursor: not-allowed;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
&.has-children {
|
|
266
|
+
padding-right: 0.5rem;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.item-label {
|
|
270
|
+
flex: 1;
|
|
271
|
+
white-space: nowrap;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.item-shortcut {
|
|
275
|
+
font-size: 0.75rem;
|
|
276
|
+
color: #6b7280;
|
|
277
|
+
font-family: 'Consolas', 'Monaco', monospace;
|
|
278
|
+
margin-left: 1rem;
|
|
279
|
+
white-space: nowrap;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.item-chevron {
|
|
283
|
+
font-size: 0.75rem;
|
|
284
|
+
color: #6b7280;
|
|
285
|
+
margin-left: 0.5rem;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Submenu styling
|
|
290
|
+
.dx-context-submenu {
|
|
291
|
+
position: absolute;
|
|
292
|
+
top: 0;
|
|
293
|
+
left: 100%;
|
|
294
|
+
margin-left: 2px;
|
|
295
|
+
background: #2d2d2d;
|
|
296
|
+
border: 1px solid #404040;
|
|
297
|
+
border-radius: 0.375rem;
|
|
298
|
+
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4);
|
|
299
|
+
min-width: 280px;
|
|
300
|
+
max-width: 360px;
|
|
301
|
+
overflow: hidden;
|
|
302
|
+
padding: 0.25rem 0;
|
|
303
|
+
z-index: 1001;
|
|
304
|
+
|
|
305
|
+
// Open to the left when near right viewport edge
|
|
306
|
+
&.open-left {
|
|
307
|
+
left: auto;
|
|
308
|
+
right: 100%;
|
|
309
|
+
margin-left: 0;
|
|
310
|
+
margin-right: 2px;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
</style>
|