Markdown editor (#92)

* Markdown editor

* use nocookie YT iframes

Co-authored-by: Emma Alexia Triphora <emma@modrinth.com>

* Fix line prefix-related Markdown editor bugs and add auto-lists

* Fix a couple codeblock issues

* address SearchFilter composition

* standardize code and patternize editor

* make editor typesafe

* adjust imports

* simplify key press handler

* Codemirror markdown implementation (#106)

* demo

* custom newline logic

* basic editor styling and buttons

* propogate styles

* validate and command structure for modals

* mobile safari event fix

* remove url field causing remount

* browser & mobile fix for link insertion

* override event passthrough to fix mobile

* fix modal state & disallow invalid url submission

* override paste behavior

* remove block flag in favor of newline insert

* cleanup before pr

* emit value from editor

* remove "a"

---------

Co-authored-by: Emma Alexia Triphora <emma@modrinth.com>
Co-authored-by: Carter <safe@fea.st>
This commit is contained in:
Prospector
2023-10-20 16:55:38 -07:00
committed by GitHub
parent 97cb5bc12c
commit c296597427
23 changed files with 1283 additions and 9 deletions

View File

@@ -43,6 +43,7 @@ export default {
{ text: 'Toggle', link: '/components/toggle' },
{ text: 'Promotion', link: '/components/promotion' },
{ text: 'Markdown', link: '/components/markdown' },
{ text: 'Markdown Editor', link: '/components/markdown-editor' },
{ text: 'Copy Code', link: '/components/copy-code' },
{ text: 'Notifications', link: '/components/notifications' },
{ text: 'Share Modal', link: '/components/share-modal' },

View File

@@ -0,0 +1,36 @@
# Markdown Editor
<script setup>
import { ref } from "vue";
const description = ref(null)
const description2 = ref(null)
</script>
The Markdown editor allows for easy formatting of Markdown text whether the user is familiar with Markdown or not. It includes standard shortcuts such as `CTRL+B` for bold, `CTRL+I` for italic, and more.
## Full editor
<DemoContainer>
<MarkdownEditor v-model="description" />
</DemoContainer>
```vue
<script setup>
import { ref } from "vue";
const description = ref(null)
</script>
<MarkdownEditor v-model="description" />
```
## Without heading buttons
<DemoContainer>
<MarkdownEditor v-model="description2" :heading-buttons="false" />
</DemoContainer>
```vue
<script setup>
import { ref } from "vue";
const description = ref(null)
</script>
<MarkdownEditor v-model="description" :heading-buttons="false" />
```

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bold"><path d="M14 12a4 4 0 0 0 0-8H6v8"/><path d="M15 20a4 4 0 0 0 0-8H6v8Z"/></svg>

After

Width:  |  Height:  |  Size: 287 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-heading-1"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="m17 12 3-2v8"/></svg>

After

Width:  |  Height:  |  Size: 301 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-heading-2"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M21 18h-4c0-4 4-3 4-6 0-1.5-2-2.5-4-1"/></svg>

After

Width:  |  Height:  |  Size: 326 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-heading-3"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M17.5 10.5c1.7-1 3.5 0 3.5 1.5a2 2 0 0 1-2 2"/><path d="M17 17.5c2 1.5 4 .3 4-1.5a2 2 0 0 0-2-2"/></svg>

After

Width:  |  Height:  |  Size: 384 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-italic"><line x1="19" x2="10" y1="4" y2="4"/><line x1="14" x2="5" y1="20" y2="20"/><line x1="15" x2="9" y1="4" y2="20"/></svg>

After

Width:  |  Height:  |  Size: 328 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-list"><line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/></svg>

After

Width:  |  Height:  |  Size: 444 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-list-ordered"><line x1="10" x2="21" y1="6" y2="6"/><line x1="10" x2="21" y1="12" y2="12"/><line x1="10" x2="21" y1="18" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg>

After

Width:  |  Height:  |  Size: 418 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-redo"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3l3 2.7"/></svg>

After

Width:  |  Height:  |  Size: 289 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-strikethrough"><path d="M16 4H9a3 3 0 0 0-2.83 4"/><path d="M14 12a4 4 0 0 1 0 8H6"/><line x1="4" x2="20" y1="12" y2="12"/></svg>

After

Width:  |  Height:  |  Size: 331 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-text-quote"><path d="M17 6H3"/><path d="M21 12H8"/><path d="M21 18H8"/><path d="M3 12v6"/></svg>

After

Width:  |  Height:  |  Size: 298 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-underline"><path d="M6 4v6a6 6 0 0 0 12 0V4"/><line x1="4" x2="20" y1="20" y2="20"/></svg>

After

Width:  |  Height:  |  Size: 292 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-youtube"><path d="M2.5 17a24.12 24.12 0 0 1 0-10 2 2 0 0 1 1.4-1.4 49.56 49.56 0 0 1 16.2 0A2 2 0 0 1 21.5 7a24.12 24.12 0 0 1 0 10 2 2 0 0 1-1.4 1.4 49.55 49.55 0 0 1-16.2 0A2 2 0 0 1 2.5 17"/><path d="m10 15 5-3-5-3z"/></svg>

After

Width:  |  Height:  |  Size: 429 B

View File

@@ -1014,6 +1014,7 @@ a,
video {
aspect-ratio: 16 / 9;
width: 850px;
max-width: 100%;
height: auto;
}

View File

@@ -43,9 +43,11 @@ a.uncolored {
}
input[type='text'],
input[type='url'],
input[type='number'],
input[type='password'],
textarea {
textarea,
.cm-content {
border-radius: var(--radius-md);
box-sizing: border-box;
// safari iOS rounds inputs by default

View File

@@ -0,0 +1,643 @@
<template>
<Modal ref="linkModal" header="Insert link">
<div class="modal-insert">
<label class="label" for="insert-link-label">
<span class="label__title">Label</span>
</label>
<div class="iconified-input">
<AlignLeftIcon />
<input id="insert-link-label" v-model="linkText" type="text" placeholder="Enter label..." />
<Button @click="() => (linkText = '')">
<XIcon />
</Button>
</div>
<label class="label" for="insert-link-url">
<span class="label__title">URL<span class="required">*</span></span>
</label>
<div class="iconified-input">
<LinkIcon />
<input
id="insert-link-url"
v-model="linkUrl"
type="text"
placeholder="Enter the link's URL..."
@input="validateURL"
/>
<Button @click="() => (linkUrl = '')">
<XIcon />
</Button>
</div>
<template v-if="linkValidationErrorMessage">
<span class="label">
<span class="label__title">Error</span>
<span class="label__description">{{ linkValidationErrorMessage }}</span>
</span>
</template>
<span class="label">
<span class="label__title">Preview</span>
<span class="label__description"></span>
</span>
<div
style="width: 100%"
class="markdown-body"
v-html="renderHighlightedString(linkMarkdown)"
/>
<div class="input-group push-right">
<Button :action="() => linkModal?.hide()"><XIcon /> Cancel</Button>
<Button
color="primary"
:disabled="linkValidationErrorMessage || !linkUrl"
:action="
() => {
if (editor) markdownCommands.replaceSelection(editor, linkMarkdown)
linkModal?.hide()
}
"
><PlusIcon /> Insert</Button
>
</div>
</div>
</Modal>
<Modal ref="imageModal" header="Insert image">
<div class="modal-insert">
<label class="label" for="insert-image-alt">
<span class="label__title">Description (alt text)<span class="required">*</span></span>
<span class="label__description">
Describe the image completely as you would to someone who could not see the image.
</span>
</label>
<div class="iconified-input">
<AlignLeftIcon />
<input
id="insert-image-alt"
v-model="linkText"
type="text"
placeholder="Describe the image..."
/>
<Button @click="() => (linkText = '')">
<XIcon />
</Button>
</div>
<label class="label" for="insert-link-url">
<span class="label__title">URL<span class="required">*</span></span>
</label>
<div class="iconified-input">
<ImageIcon />
<input
id="insert-link-url"
v-model="linkUrl"
type="text"
placeholder="Enter the image URL..."
@input="validateURL"
/>
<Button @click="() => (linkUrl = '')">
<XIcon />
</Button>
</div>
<template v-if="linkValidationErrorMessage">
<span class="label">
<span class="label__title">Error</span>
<span class="label__description">{{ linkValidationErrorMessage }}</span>
</span>
</template>
<span class="label">
<span class="label__title">Preview</span>
<span class="label__description"></span>
</span>
<div
style="width: 100%"
class="markdown-body"
v-html="renderHighlightedString(imageMarkdown)"
/>
<div class="input-group push-right">
<Button :action="() => imageModal?.hide()"><XIcon /> Cancel</Button>
<Button
color="primary"
:disabled="linkValidationErrorMessage || !linkUrl"
:action="
() => {
if (editor) markdownCommands.replaceSelection(editor, imageMarkdown)
imageModal?.hide()
}
"
>
<PlusIcon /> Insert
</Button>
</div>
</div>
</Modal>
<Modal ref="videoModal" header="Insert YouTube video">
<div class="modal-insert">
<label class="label" for="insert-video-url">
<span class="label__title">YouTube video URL<span class="required">*</span></span>
<span class="label__description"> Enter a valid link to a YouTube video. </span>
</label>
<div class="iconified-input">
<YouTubeIcon />
<input
id="insert-video-url"
v-model="linkUrl"
type="text"
placeholder="Enter YouTube video URL"
@input="validateURL"
/>
<Button @click="() => (linkUrl = '')">
<XIcon />
</Button>
</div>
<template v-if="linkValidationErrorMessage">
<span class="label">
<span class="label__title">Error</span>
<span class="label__description">{{ linkValidationErrorMessage }}</span>
</span>
</template>
<span class="label">
<span class="label__title">Preview</span>
<span class="label__description"></span>
</span>
<div
style="width: 100%"
class="markdown-body"
v-html="renderHighlightedString(videoMarkdown)"
/>
<div class="input-group push-right">
<Button :action="() => videoModal?.hide()"><XIcon /> Cancel</Button>
<Button
color="primary"
:disabled="linkValidationErrorMessage || !linkUrl"
:action="
() => {
if (editor) markdownCommands.replaceSelection(editor, videoMarkdown)
videoModal?.hide()
}
"
>
<PlusIcon /> Insert
</Button>
</div>
</div>
</Modal>
<div class="resizable-textarea-wrapper">
<div class="editor-action-row">
<div class="editor-actions">
<template
v-for="(buttonGroup, _i) in Object.values(BUTTONS).filter((bg) => bg.display)"
:key="_i"
>
<div class="divider"></div>
<template v-for="button in buttonGroup.buttons" :key="button.label">
<Button
v-tooltip="button.label"
icon-only
:aria-label="button.label"
:class="{ 'mobile-hidden-group': !!buttonGroup.hideOnMobile }"
:action="() => button.action(editor)"
:disabled="previewMode || disabled"
>
<component :is="button.icon" />
</Button>
</template>
</template>
</div>
<div class="preview">
<Toggle id="preview" v-model="previewMode" />
<label class="label" for="preview"> Preview </label>
</div>
</div>
<div ref="editorRef" :class="{ hide: previewMode }" />
<div v-if="!previewMode" class="info-blurb">
<InfoIcon />
<span>
This editor supports
<a href="https://docs.modrinth.com/docs/markdown" target="_blank">Markdown formatting</a>.
</span>
</div>
<div
v-if="previewMode"
style="width: 100%"
class="markdown-body"
v-html="renderHighlightedString(currentValue ?? '')"
/>
</div>
</template>
<script setup lang="ts">
import { type Component, computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { EditorState } from '@codemirror/state'
import { EditorView, keymap } from '@codemirror/view'
import { markdown } from '@codemirror/lang-markdown'
import { indentWithTab, historyKeymap, history } from '@codemirror/commands'
import {
Heading1Icon,
Heading2Icon,
Heading3Icon,
BoldIcon,
ItalicIcon,
StrikethroughIcon,
CodeIcon,
ListBulletedIcon,
ListOrderedIcon,
TextQuoteIcon,
LinkIcon,
ImageIcon,
YouTubeIcon,
AlignLeftIcon,
PlusIcon,
XIcon,
Button,
Modal,
Toggle,
} from '@/components'
import { markdownCommands, modrinthMarkdownEditorKeymap } from '@/helpers/codemirror'
import { renderHighlightedString } from '@/helpers/highlight'
const props = defineProps({
modelValue: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
headingButtons: {
type: Boolean,
default: true,
},
})
const editorRef = ref<HTMLDivElement>()
let editor: EditorView | null = null
const emit = defineEmits(['update:modelValue'])
onMounted(() => {
const updateListener = EditorView.updateListener.of((update) => {
if (update.docChanged) {
currentValue.value = update.state.doc.toString()
emit('update:modelValue', currentValue.value)
}
})
const theme = EditorView.theme({
// in defualts.scss there's references to .cm-content and such to inherit global styles
'.cm-content, .cm-gutter': {
marginBlockEnd: '0.5rem',
padding: '0.5rem',
minHeight: '200px',
caretColor: 'var(--color-contrast)',
width: '100%',
overflowX: 'scroll',
},
'.cm-scroller': {
height: '100%',
overflow: 'visible',
},
})
const eventHandlers = EditorView.domEventHandlers({
paste: (ev, view) => {
// If the user's pasting a url, automatically convert it to a link with the selection as the text or the url itself if no selection content.
const url = ev.clipboardData?.getData('text/plain')
if (url) {
try {
cleanUrl(url)
} catch (error: unknown) {
if (error instanceof Error) {
return
}
}
const selection = view.state.selection.main
const selectionText = view.state.doc.sliceString(selection.from, selection.to)
const linkText = selectionText ? selectionText : url
const linkMarkdown = `[${linkText}](${url})`
return markdownCommands.replaceSelection(view, linkMarkdown)
}
},
})
const editorState = EditorState.create({
doc: props.modelValue,
extensions: [
theme,
eventHandlers,
updateListener,
keymap.of([indentWithTab]),
keymap.of(modrinthMarkdownEditorKeymap),
history(),
markdown({
addKeymap: false,
}),
keymap.of(historyKeymap),
],
})
editor = new EditorView({
state: editorState,
parent: editorRef.value,
doc: props.modelValue,
})
})
onBeforeUnmount(() => {
editor?.destroy()
})
type ButtonAction = {
label: string
icon: Component
action: (editor: EditorView | null) => void
}
type ButtonGroup = {
display: boolean
hideOnMobile: boolean
buttons: ButtonAction[]
}
type ButtonGroupMap = {
[key: string]: ButtonGroup
}
function runEditorCommand(command: (view: EditorView) => boolean, editor: EditorView | null) {
if (editor) {
command(editor)
editor.focus()
}
}
const composeCommandButton = (
name: string,
icon: Component,
command: (view: EditorView) => boolean
) => {
return {
label: name,
icon,
action: (e: EditorView | null) => runEditorCommand(command, e),
}
}
const BUTTONS: ButtonGroupMap = {
headings: {
display: props.headingButtons,
hideOnMobile: false,
buttons: [
composeCommandButton('Heading 1', Heading1Icon, markdownCommands.toggleHeader),
composeCommandButton('Heading 2', Heading2Icon, markdownCommands.toggleHeader2),
composeCommandButton('Heading 3', Heading3Icon, markdownCommands.toggleHeader3),
],
},
stylizing: {
display: true,
hideOnMobile: false,
buttons: [
composeCommandButton('Bold', BoldIcon, markdownCommands.toggleBold),
composeCommandButton('Italic', ItalicIcon, markdownCommands.toggleItalic),
composeCommandButton(
'Strikethrough',
StrikethroughIcon,
markdownCommands.toggleStrikethrough
),
composeCommandButton('Code', CodeIcon, markdownCommands.toggleCodeBlock),
],
},
lists: {
display: true,
hideOnMobile: false,
buttons: [
composeCommandButton('Bulleted list', ListBulletedIcon, markdownCommands.toggleBulletList),
composeCommandButton('Ordered list', ListOrderedIcon, markdownCommands.toggleOrderedList),
composeCommandButton('Quote', TextQuoteIcon, markdownCommands.toggleQuote),
],
},
components: {
display: true,
hideOnMobile: false,
buttons: [
{
label: 'Link',
icon: LinkIcon,
action: () => openLinkModal(),
},
{
label: 'Image',
icon: ImageIcon,
action: () => openImageModal(),
},
{
label: 'Video',
icon: YouTubeIcon,
action: () => openVideoModal(),
},
],
},
}
const currentValue = ref(props.modelValue)
const previewMode = ref(false)
const linkText = ref('')
const linkUrl = ref('')
const linkValidationErrorMessage = ref<string | undefined>()
function validateURL() {
if (!linkUrl.value || linkUrl.value === '') {
linkValidationErrorMessage.value = undefined
return
}
try {
linkValidationErrorMessage.value = undefined
cleanUrl(linkUrl.value)
} catch (e: unknown) {
if (e instanceof Error) {
linkValidationErrorMessage.value = e.message
}
}
}
function cleanUrl(input: string): string {
let url
// Attempt to validate and parse the URL
try {
url = new URL(input)
} catch (e) {
throw new Error('Invalid URL. Make sure the URL is well-formed.')
}
// Check for unsupported protocols
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('Unsupported protocol. Use http or https.')
}
// If the scheme is "http", automatically upgrade it to "https"
if (url.protocol === 'http:') {
url.protocol = 'https:'
}
return url.toString()
}
const linkMarkdown = computed(() => {
if (!linkUrl.value) {
return ''
}
try {
const url = cleanUrl(linkUrl.value)
return url ? `[${linkText.value ? linkText.value : url}](${url})` : ''
} catch (error: unknown) {
if (error instanceof Error) {
console.error(error.message)
}
}
return ''
})
const imageMarkdown = computed(() => (linkMarkdown.value.length ? `!${linkMarkdown.value}` : ''))
const youtubeRegex =
/^(?:https?:)?(?:\/\/)?(?:youtu\.be\/|(?:www\.|m\.)?youtube\.com\/(?:watch|v|embed)(?:\.php)?(?:\?.*v=|\/))([a-zA-Z0-9_-]{7,15})(?:[?&][a-zA-Z0-9_-]+=[a-zA-Z0-9_-]+)*$/
const videoMarkdown = computed(() => {
const match = youtubeRegex.exec(linkUrl.value)
if (match) {
return `<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/${match[1]}" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>`
} else {
return ''
}
})
const linkModal = ref<InstanceType<typeof Modal> | null>(null)
const imageModal = ref<InstanceType<typeof Modal> | null>(null)
const videoModal = ref<InstanceType<typeof Modal> | null>(null)
function openLinkModal() {
if (editor) linkText.value = markdownCommands.yankSelection(editor)
linkUrl.value = ''
linkModal.value?.show()
}
function openImageModal() {
linkText.value = ''
linkUrl.value = ''
imageModal.value?.show()
}
function openVideoModal() {
linkText.value = ''
linkUrl.value = ''
videoModal.value?.show()
}
</script>
<style scoped>
.display-options {
margin-bottom: var(--gap-sm);
}
.editor-action-row {
display: flex;
align-items: center;
flex-wrap: wrap;
overflow: hidden;
justify-content: space-between;
margin-bottom: var(--gap-sm);
gap: var(--gap-xs);
@media (max-width: 768px) {
flex-direction: column;
align-items: start;
}
}
.editor-actions {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: var(--gap-xs);
@media (max-width: 768px) {
.divider {
display: none;
}
.mobile-hidden-group {
display: none;
}
}
.divider {
width: 0.125rem;
height: 1.8rem;
background-color: var(--color-button-bg);
border-radius: var(--radius-max);
margin-inline: var(--gap-xs);
}
.divider:first-child {
display: none;
}
}
.resizable-textarea-wrapper textarea {
min-height: 10rem;
width: 100%;
}
.info-blurb {
display: flex;
align-items: center;
gap: var(--gap-xs);
}
.hide {
display: none;
}
.preview {
display: flex;
align-items: center;
justify-items: end;
gap: var(--gap-xs);
}
.markdown-body {
border: 1px solid var(--color-button-bg);
border-radius: var(--radius-md);
padding: var(--radius-md);
min-height: 6rem;
}
.modal-insert {
padding: var(--gap-lg);
.iconified-input {
width: 100%;
}
.label {
margin-block: var(--gap-lg) var(--gap-sm);
display: block;
}
.label__title {
color: var(--color-contrast);
display: block;
font-size: 1.17rem;
font-weight: 700;
.required {
color: var(--color-red);
}
}
.input-group {
margin-top: var(--gap-lg);
}
}
</style>

View File

@@ -12,6 +12,7 @@ export { default as DropArea } from './base/DropArea.vue'
export { default as DropdownSelect } from './base/DropdownSelect.vue'
export { default as EnvironmentIndicator } from './base/EnvironmentIndicator.vue'
export { default as FileInput } from './base/FileInput.vue'
export { default as MarkdownEditor } from './base/MarkdownEditor.vue'
export { default as Notifications } from './base/Notifications.vue'
export { default as OverflowMenu } from './base/OverflowMenu.vue'
export { default as Page } from './base/Page.vue'
@@ -69,6 +70,7 @@ export { default as PayPalIcon } from '@/assets/external/paypal.svg?component'
export { default as RedditIcon } from '@/assets/external/reddit.svg?component'
export { default as TwitterIcon } from '@/assets/external/twitter.svg?component'
export { default as WindowsIcon } from '@/assets/external/windows.svg?component'
export { default as YouTubeIcon } from '@/assets/icons/youtube.svg?component'
// Icons
export { default as AlignLeftIcon } from '@/assets/icons/align-left.svg?component'
@@ -170,6 +172,7 @@ export { default as TerminalSquareIcon } from '@/assets/icons/terminal-square.sv
export { default as TransferIcon } from '@/assets/icons/transfer.svg?component'
export { default as TrashIcon } from '@/assets/icons/trash.svg?component'
export { default as UndoIcon } from '@/assets/icons/undo.svg?component'
export { default as RedoIcon } from '@/assets/icons/redo.svg?component'
export { default as UnknownIcon } from '@/assets/icons/unknown.svg?component'
export { default as UnknownDonationIcon } from '@/assets/icons/unknown-donation.svg?component'
export { default as UpdatedIcon } from '@/assets/icons/updated.svg?component'
@@ -182,3 +185,15 @@ export { default as VersionIcon } from '@/assets/icons/version.svg?component'
export { default as WikiIcon } from '@/assets/icons/wiki.svg?component'
export { default as XIcon } from '@/assets/icons/x.svg?component'
export { default as XCircleIcon } from '@/assets/icons/x-circle.svg?component'
// Editor Icons
export { default as BoldIcon } from '@/assets/icons/bold.svg?component'
export { default as ItalicIcon } from '@/assets/icons/italic.svg?component'
export { default as UnderlineIcon } from '@/assets/icons/underline.svg?component'
export { default as StrikethroughIcon } from '@/assets/icons/strikethrough.svg?component'
export { default as ListBulletedIcon } from '@/assets/icons/list-bulleted.svg?component'
export { default as ListOrderedIcon } from '@/assets/icons/list-ordered.svg?component'
export { default as TextQuoteIcon } from '@/assets/icons/text-quote.svg?component'
export { default as Heading1Icon } from '@/assets/icons/heading-1.svg?component'
export { default as Heading2Icon } from '@/assets/icons/heading-2.svg?component'
export { default as Heading3Icon } from '@/assets/icons/heading-3.svg?component'

View File

@@ -1,24 +1,25 @@
<template>
<Checkbox
class="filter"
:model-value="activeFilters.includes(facetName)"
:model-value="isActive"
:description="displayName"
@update:model-value="toggle()"
@update:model-value="toggle"
>
<div class="filter-text">
<div v-if="icon" aria-hidden="true" class="icon" v-html="icon" />
<div v-if="props.icon" aria-hidden="true" class="icon" v-html="props.icon" />
<div v-else class="icon">
<slot />
</div>
<span aria-hidden="true"> {{ displayName }}</span>
<span aria-hidden="true"> {{ props.displayName }}</span>
</div>
</Checkbox>
</template>
<script setup>
import { ref, defineProps, defineEmits, watchEffect } from 'vue'
import { Checkbox } from '@'
defineProps({
const props = defineProps({
facetName: {
type: String,
default: '',
@@ -39,10 +40,15 @@ defineProps({
},
})
const isActive = ref(props.activeFilters.value.includes(props.facetName))
const emit = defineEmits(['toggle'])
function toggle() {
emit('toggle', this.facetName)
watchEffect(() => {
isActive.value = props.activeFilters.value.includes(props.facetName)
})
const toggle = () => {
emit('toggle', props.facetName)
}
</script>

370
lib/helpers/codemirror.ts Normal file
View File

@@ -0,0 +1,370 @@
import { insertNewlineAndIndent } from '@codemirror/commands'
import { deleteMarkupBackward } from '@codemirror/lang-markdown'
import { getIndentation, indentString, syntaxTree } from '@codemirror/language'
import { type EditorState, type Transaction } from '@codemirror/state'
import { type EditorView, type Command, type KeyBinding } from '@codemirror/view'
const toggleBold: Command = ({ state, dispatch }) => {
return toggleAround(state, dispatch, '**', '**')
}
const toggleItalic: Command = ({ state, dispatch }) => {
return toggleAround(state, dispatch, '_', '_')
}
const toggleStrikethrough: Command = ({ state, dispatch }) => {
return toggleAround(state, dispatch, '~~', '~~')
}
const toggleCodeBlock: Command = ({ state, dispatch }) => {
const lineBreak = state.lineBreak
const codeBlockMark = lineBreak + '```' + lineBreak
return toggleAround(state, dispatch, codeBlockMark, codeBlockMark)
}
const toggleHeader: Command = ({ state, dispatch }) => {
return toggleLineStart(state, dispatch, '# ')
}
const toggleHeader2: Command = ({ state, dispatch }) => {
return toggleLineStart(state, dispatch, '## ')
}
const toggleHeader3: Command = ({ state, dispatch }) => {
return toggleLineStart(state, dispatch, '### ')
}
const toggleHeader4: Command = ({ state, dispatch }) => {
return toggleLineStart(state, dispatch, '#### ')
}
const toggleHeader5: Command = ({ state, dispatch }) => {
return toggleLineStart(state, dispatch, '##### ')
}
const toggleHeader6: Command = ({ state, dispatch }) => {
return toggleLineStart(state, dispatch, '###### ')
}
const toggleQuote: Command = ({ state, dispatch }) => {
return toggleLineStart(state, dispatch, '> ')
}
const toggleBulletList: Command = ({ state, dispatch }) => {
return toggleLineStart(state, dispatch, '- ')
}
const toggleOrderedList: Command = ({ state, dispatch }) => {
return toggleLineStart(state, dispatch, '1. ')
}
const yankSelection = ({ state }: EditorView): string => {
const { from, to } = state.selection.main
const selectedText = state.doc.sliceString(from, to)
return selectedText
}
const replaceSelection = ({ state, dispatch }: EditorView, text: string) => {
const { from, to } = state.selection.main
const transaction = state.update({
changes: { from, to, insert: text },
selection: { anchor: from + text.length, head: from + text.length },
})
dispatch(transaction)
return true
}
type Dispatch = (tr: Transaction) => void
const surroundedByText = (
state: EditorState,
open: string,
close: string
): 'inclusive' | 'exclusive' | 'none' => {
const { from, to } = state.selection.main
// Check for inclusive surrounding first
const selectedText = state.doc.sliceString(from, to)
if (selectedText.startsWith(open) && selectedText.endsWith(close)) {
return 'inclusive'
}
// Then check for exclusive surrounding
const beforeText = state.doc.sliceString(Math.max(0, from - open.length), from)
const afterText = state.doc.sliceString(to, to + close.length)
if (beforeText === open && afterText === close) {
return 'exclusive'
}
// Return 'none' if no surrounding detected
return 'none'
}
// TODO: Node based toggleAround so that we can support nested delimiters
const toggleAround = (
state: EditorState,
dispatch: Dispatch,
open: string,
close: string
): boolean => {
const { from, to } = state.selection.main
const isSurrounded = surroundedByText(state, open, close)
if (isSurrounded !== 'none') {
const isInclusive = isSurrounded === 'inclusive'
let transaction: Transaction
if (isInclusive) {
// Remove delimiters on the inside edges of the selected text
transaction = state.update({
changes: [
{ from, to: from + open.length, insert: '' },
{ from: to - close.length, to, insert: '' },
],
})
} else {
// Remove delimiters on the outside edges of the selected text
transaction = state.update({
changes: [
{ from: from - open.length, to: from, insert: '' },
{ from: to, to: to + close.length, insert: '' },
],
})
}
dispatch(transaction)
return true
}
// Add delimiters around the selected text
const transaction = state.update({
changes: [
{ from, insert: open },
{ from: to, insert: close },
],
selection: { anchor: from + open.length, head: to + open.length },
})
dispatch(transaction)
return true
}
const toggleLineStart = (state: EditorState, dispatch: Dispatch, text: string): boolean => {
const lines = state.doc.lineAt(state.selection.main.from)
const lineBreak = state.lineBreak
const range = {
from: lines.from,
to: state.selection.main.to,
}
const selectedText = state.doc.sliceString(range.from, range.to)
const shouldRemove = selectedText.startsWith(text)
let transaction: Transaction | undefined
if (shouldRemove) {
const numOfSelectedLinesThatNeedToBeRemoved = selectedText.split(lineBreak + text).length
const modifiedText = selectedText.substring(text.length).replaceAll(lineBreak + text, lineBreak)
transaction = state.update({
changes: { from: range.from, to: range.to, insert: modifiedText },
selection: {
anchor: state.selection.main.from - text.length,
head: state.selection.main.to - text.length * numOfSelectedLinesThatNeedToBeRemoved,
},
})
} else {
const modifiedText = text + selectedText.replaceAll(lineBreak, lineBreak + text)
const lengthDiff = modifiedText.length - selectedText.length
transaction = state.update({
changes: { from: range.from, to: range.to, insert: modifiedText },
selection: {
anchor: state.selection.main.from + text.length,
head: state.selection.main.to + lengthDiff,
},
})
}
if (!transaction) return false
dispatch(transaction)
return true
}
const continueNodeTypes = ['ListItem', 'Blockquote']
const blackListedNodeTypes = ['CodeBlock']
const getListStructure = (state: EditorState, head: number) => {
const tree = syntaxTree(state)
const headNode = tree.resolve(head, -1)
const stack = []
let node: typeof headNode.parent = headNode
while (node) {
if (continueNodeTypes.includes(node.name)) {
stack.push(node)
}
if (blackListedNodeTypes.includes(node.name)) {
return null
}
if (node.name === 'Document') {
break
}
node = node.parent
}
return stack
}
const insertNewlineContinueMark: Command = (view): boolean => {
const { state, dispatch } = view
const {
selection: {
main: { head },
},
} = state
// Get the current list structure to examine
const stack = getListStructure(state, head)
if (!stack || stack.length === 0) {
// insert a newline as normal so that mobile works
return insertNewlineAndIndent(view)
}
const lastNode = stack[stack.length - 1]
// Get the necessary indentation
const indentation = getIndentation(state, head)
const indentStr = indentation ? indentString(state, indentation) : ''
// Initialize a transaction variable
let transaction: Transaction | undefined
const lineContent = state.doc.lineAt(head).text
// Identify the patterns that should cancel the list continuation
// TODO: Implement Node based cancellation
const cancelPatterns = ['```', '# ', '> ']
const listMark = lastNode.getChild('ListMark')
if (listMark) {
cancelPatterns.push(state.doc.sliceString(listMark.from, listMark.to) + ' ')
}
// Skip if current line matches any of the cancel patterns
if (cancelPatterns.includes(lineContent)) {
transaction = createSimpleTransaction(state)
dispatch(transaction)
return true
}
switch (lastNode.name) {
case 'ListItem':
if (!listMark) return false
transaction = createListTransaction(state, indentStr, listMark.from, listMark.to)
break
case 'Blockquote':
transaction = createBlockquoteTransaction(state, indentStr)
break
}
if (transaction) {
dispatch(transaction)
return true
}
return false
}
// Creates a transaction for a simple line break
const createSimpleTransaction = (state: EditorState) => {
const {
lineBreak,
selection: {
main: { head },
},
} = state
const line = state.doc.lineAt(head)
return state.update({
changes: {
from: line.from,
to: line.to,
insert: lineBreak,
},
})
}
// Creates a transaction for continuing a list item
const createListTransaction = (state: EditorState, indentStr: string, from: number, to: number) => {
const {
lineBreak,
selection: {
main: { head },
},
} = state
const listMarkContent = state.doc.sliceString(from, to)
const insert = `${lineBreak}${indentStr}${incrementMark(listMarkContent)} `
return state.update({
changes: { from: head, insert },
selection: { anchor: head + insert.length, head: head + insert.length },
})
}
// Creates a transaction for continuing a blockquote
const createBlockquoteTransaction = (state: EditorState, indentStr: string) => {
const {
lineBreak,
selection: {
main: { head },
},
} = state
const insert = `${lineBreak}${indentStr}> `
return state.update({
changes: { from: head, insert },
selection: { anchor: head + insert.length, head: head + insert.length },
})
}
const incrementMark = (mark: string): string => {
const numberedListRegex = /^(\d+)\.$/
const match = numberedListRegex.exec(mark)
if (match) {
const number = parseInt(match[1])
return (number + 1).toString() + '.'
}
return mark
}
const commands = {
toggleBold,
toggleItalic,
toggleStrikethrough,
toggleCodeBlock,
toggleHeader,
toggleHeader2,
toggleHeader3,
toggleHeader4,
toggleHeader5,
toggleHeader6,
toggleQuote,
toggleBulletList,
toggleOrderedList,
insertNewlineContinueMark,
// Utility
yankSelection,
replaceSelection,
}
export const markdownCommands = commands
export const modrinthMarkdownEditorKeymap: KeyBinding[] = [
{ key: 'Enter', run: insertNewlineContinueMark },
{ key: 'Backspace', run: deleteMarkupBackward },
{ key: 'Mod-b', run: toggleBold },
{ key: 'Mod-i', run: toggleItalic },
{ key: 'Mod-e', run: toggleCodeBlock },
{ key: 'Mod-s', run: toggleStrikethrough },
{ key: 'Mod-Shift-.', run: toggleQuote },
]

View File

@@ -25,6 +25,11 @@
"docs:preview": "vitepress preview docs"
},
"dependencies": {
"@codemirror/commands": "^6.3.0",
"@codemirror/lang-markdown": "^6.2.2",
"@codemirror/language": "^6.9.1",
"@codemirror/state": "^6.3.0",
"@codemirror/view": "^6.21.3",
"chart.js": "^4.3.3",
"dayjs": "^1.11.7",
"floating-vue": "^2.0.0-beta.20",

180
pnpm-lock.yaml generated
View File

@@ -1,6 +1,25 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
dependencies:
'@codemirror/commands':
specifier: ^6.3.0
version: 6.3.0
'@codemirror/lang-markdown':
specifier: ^6.2.2
version: 6.2.2
'@codemirror/language':
specifier: ^6.9.1
version: 6.9.1
'@codemirror/state':
specifier: ^6.3.0
version: 6.3.0
'@codemirror/view':
specifier: ^6.21.3
version: 6.21.3
chart.js:
specifier: ^4.3.3
version: 4.3.3
@@ -233,6 +252,110 @@ packages:
'@babel/helper-validator-identifier': 7.19.1
to-fast-properties: 2.0.0
/@codemirror/autocomplete@6.10.2(@codemirror/language@6.9.1)(@codemirror/state@6.3.0)(@codemirror/view@6.21.3)(@lezer/common@1.1.0):
resolution: {integrity: sha512-3dCL7b0j2GdtZzWN5j7HDpRAJ26ip07R4NGYz7QYthIYMiX8I4E4TNrYcdTayPJGeVQtd/xe7lWU4XL7THFb/w==}
peerDependencies:
'@codemirror/language': ^6.0.0
'@codemirror/state': ^6.0.0
'@codemirror/view': ^6.0.0
'@lezer/common': ^1.0.0
dependencies:
'@codemirror/language': 6.9.1
'@codemirror/state': 6.3.0
'@codemirror/view': 6.21.3
'@lezer/common': 1.1.0
dev: false
/@codemirror/commands@6.3.0:
resolution: {integrity: sha512-tFfcxRIlOWiQDFhjBSWJ10MxcvbCIsRr6V64SgrcaY0MwNk32cUOcCuNlWo8VjV4qRQCgNgUAnIeo0svkk4R5Q==}
dependencies:
'@codemirror/language': 6.9.1
'@codemirror/state': 6.3.0
'@codemirror/view': 6.21.3
'@lezer/common': 1.1.0
dev: false
/@codemirror/lang-css@6.2.1(@codemirror/view@6.21.3):
resolution: {integrity: sha512-/UNWDNV5Viwi/1lpr/dIXJNWiwDxpw13I4pTUAsNxZdg6E0mI2kTQb0P2iHczg1Tu+H4EBgJR+hYhKiHKko7qg==}
dependencies:
'@codemirror/autocomplete': 6.10.2(@codemirror/language@6.9.1)(@codemirror/state@6.3.0)(@codemirror/view@6.21.3)(@lezer/common@1.1.0)
'@codemirror/language': 6.9.1
'@codemirror/state': 6.3.0
'@lezer/common': 1.1.0
'@lezer/css': 1.1.3
transitivePeerDependencies:
- '@codemirror/view'
dev: false
/@codemirror/lang-html@6.4.6:
resolution: {integrity: sha512-E4C8CVupBksXvgLSme/zv31x91g06eZHSph7NczVxZW+/K+3XgJGWNT//2WLzaKSBoxpAjaOi5ZnPU1SHhjh3A==}
dependencies:
'@codemirror/autocomplete': 6.10.2(@codemirror/language@6.9.1)(@codemirror/state@6.3.0)(@codemirror/view@6.21.3)(@lezer/common@1.1.0)
'@codemirror/lang-css': 6.2.1(@codemirror/view@6.21.3)
'@codemirror/lang-javascript': 6.2.1
'@codemirror/language': 6.9.1
'@codemirror/state': 6.3.0
'@codemirror/view': 6.21.3
'@lezer/common': 1.1.0
'@lezer/css': 1.1.3
'@lezer/html': 1.3.6
dev: false
/@codemirror/lang-javascript@6.2.1:
resolution: {integrity: sha512-jlFOXTejVyiQCW3EQwvKH0m99bUYIw40oPmFjSX2VS78yzfe0HELZ+NEo9Yfo1MkGRpGlj3Gnu4rdxV1EnAs5A==}
dependencies:
'@codemirror/autocomplete': 6.10.2(@codemirror/language@6.9.1)(@codemirror/state@6.3.0)(@codemirror/view@6.21.3)(@lezer/common@1.1.0)
'@codemirror/language': 6.9.1
'@codemirror/lint': 6.4.2
'@codemirror/state': 6.3.0
'@codemirror/view': 6.21.3
'@lezer/common': 1.1.0
'@lezer/javascript': 1.4.8
dev: false
/@codemirror/lang-markdown@6.2.2:
resolution: {integrity: sha512-wmwM9Y5n/e4ndU51KcYDaQnb9goYdhXjU71dDW9goOc1VUTIPph6WKAPdJ6BzC0ZFy+UTsDwTXGWSP370RH69Q==}
dependencies:
'@codemirror/autocomplete': 6.10.2(@codemirror/language@6.9.1)(@codemirror/state@6.3.0)(@codemirror/view@6.21.3)(@lezer/common@1.1.0)
'@codemirror/lang-html': 6.4.6
'@codemirror/language': 6.9.1
'@codemirror/state': 6.3.0
'@codemirror/view': 6.21.3
'@lezer/common': 1.1.0
'@lezer/markdown': 1.1.0
dev: false
/@codemirror/language@6.9.1:
resolution: {integrity: sha512-lWRP3Y9IUdOms6DXuBpoWwjkR7yRmnS0hKYCbSfPz9v6Em1A1UCRujAkDiCrdYfs1Z0Eu4dGtwovNPStIfkgNA==}
dependencies:
'@codemirror/state': 6.3.0
'@codemirror/view': 6.21.3
'@lezer/common': 1.1.0
'@lezer/highlight': 1.1.6
'@lezer/lr': 1.3.13
style-mod: 4.1.0
dev: false
/@codemirror/lint@6.4.2:
resolution: {integrity: sha512-wzRkluWb1ptPKdzlsrbwwjYCPLgzU6N88YBAmlZi8WFyuiEduSd05MnJYNogzyc8rPK7pj6m95ptUApc8sHKVA==}
dependencies:
'@codemirror/state': 6.3.0
'@codemirror/view': 6.21.3
crelt: 1.0.6
dev: false
/@codemirror/state@6.3.0:
resolution: {integrity: sha512-5fIS19U46PEqczbBL6gBAtju9MFDT9TjIC/q2MYblHCEKiU8jhV3cRFhvQu5tQvbtxc5KLWxSnzMNh3ZqeaXVg==}
dev: false
/@codemirror/view@6.21.3:
resolution: {integrity: sha512-8l1aSQ6MygzL4Nx7GVYhucSXvW4jQd0F6Zm3v9Dg+6nZEfwzJVqi4C2zHfDljID+73gsQrWp9TgHc81xU15O4A==}
dependencies:
'@codemirror/state': 6.3.0
style-mod: 4.1.0
w3c-keyname: 2.2.8
dev: false
/@docsearch/css@3.4.0:
resolution: {integrity: sha512-Hg8Xfma+rFwRi6Y/pfei4FJoQ1hdVURmmNs/XPoMTCPAImU+d5yxj+M+qdLtNjWRpfWziU4dQcqY94xgFBn2dg==}
dev: true
@@ -580,6 +703,51 @@ packages:
resolution: {integrity: sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==}
dev: false
/@lezer/common@1.1.0:
resolution: {integrity: sha512-XPIN3cYDXsoJI/oDWoR2tD++juVrhgIago9xyKhZ7IhGlzdDM9QgC8D8saKNCz5pindGcznFr2HBSsEQSWnSjw==}
dev: false
/@lezer/css@1.1.3:
resolution: {integrity: sha512-SjSM4pkQnQdJDVc80LYzEaMiNy9txsFbI7HsMgeVF28NdLaAdHNtQ+kB/QqDUzRBV/75NTXjJ/R5IdC8QQGxMg==}
dependencies:
'@lezer/highlight': 1.1.6
'@lezer/lr': 1.3.13
dev: false
/@lezer/highlight@1.1.6:
resolution: {integrity: sha512-cmSJYa2us+r3SePpRCjN5ymCqCPv+zyXmDl0ciWtVaNiORT/MxM7ZgOMQZADD0o51qOaOg24qc/zBViOIwAjJg==}
dependencies:
'@lezer/common': 1.1.0
dev: false
/@lezer/html@1.3.6:
resolution: {integrity: sha512-Kk9HJARZTc0bAnMQUqbtuhFVsB4AnteR2BFUWfZV7L/x1H0aAKz6YabrfJ2gk/BEgjh9L3hg5O4y2IDZRBdzuQ==}
dependencies:
'@lezer/common': 1.1.0
'@lezer/highlight': 1.1.6
'@lezer/lr': 1.3.13
dev: false
/@lezer/javascript@1.4.8:
resolution: {integrity: sha512-QRmw/5xrcyRLyWr3JT3KCzn2XZr5NYNqQMGsqnYy+FghbQn9DZPuj6JDkE6uSXvfMLpdapu8KBIaeoJFaR4QVw==}
dependencies:
'@lezer/highlight': 1.1.6
'@lezer/lr': 1.3.13
dev: false
/@lezer/lr@1.3.13:
resolution: {integrity: sha512-RLAbau/4uSzKgIKj96mI5WUtG1qtiR0Frn0Ei9zhPj8YOkHM+1Bb8SgdVvmR/aWJCFIzjo2KFnDiRZ75Xf5NdQ==}
dependencies:
'@lezer/common': 1.1.0
dev: false
/@lezer/markdown@1.1.0:
resolution: {integrity: sha512-JYOI6Lkqbl83semCANkO3CKbKc0pONwinyagBufWBm+k4yhIcqfCF8B8fpEpvJLmIy7CAfwiq7dQ/PzUZA340g==}
dependencies:
'@lezer/common': 1.1.0
'@lezer/highlight': 1.1.6
dev: false
/@microsoft/api-extractor-model@7.28.2:
resolution: {integrity: sha512-vkojrM2fo3q4n4oPh4uUZdjJ2DxQ2+RnDQL/xhTWSRUNPF6P4QyrvY357HBxbnltKcYu+nNNolVqc6TIGQ73Ig==}
dependencies:
@@ -1423,6 +1591,10 @@ packages:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
dev: true
/crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
dev: false
/cross-spawn@7.0.3:
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
engines: {node: '>= 8'}
@@ -2589,6 +2761,10 @@ packages:
engines: {node: '>=8'}
dev: true
/style-mod@4.1.0:
resolution: {integrity: sha512-Ca5ib8HrFn+f+0n4N4ScTIA9iTOQ7MaGS1ylHcoVqW9J7w2w8PzN6g9gKmTYgGEBH8e120+RCmhpje6jC5uGWA==}
dev: false
/supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@@ -2971,6 +3147,10 @@ packages:
'@vue/server-renderer': 3.3.4(vue@3.3.4)
'@vue/shared': 3.3.4
/w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
dev: false
/watchpack@2.4.0:
resolution: {integrity: sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==}
engines: {node: '>=10.13.0'}

View File

@@ -19,7 +19,10 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./lib/*"]
}
},
"include": ["lib/**/*.js", "lib/**/*.ts", "lib/**/*.d.ts", "lib/**/*.tsx", "lib/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]