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>
@@ -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' },
|
||||
|
||||
36
docs/components/markdown-editor.md
Normal 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" />
|
||||
```
|
||||
1
lib/assets/icons/bold.svg
Normal 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 |
1
lib/assets/icons/heading-1.svg
Normal 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 |
1
lib/assets/icons/heading-2.svg
Normal 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 |
1
lib/assets/icons/heading-3.svg
Normal 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 |
1
lib/assets/icons/italic.svg
Normal 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 |
1
lib/assets/icons/list-bulleted.svg
Normal 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 |
1
lib/assets/icons/list-ordered.svg
Normal 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 |
1
lib/assets/icons/redo.svg
Normal 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 |
1
lib/assets/icons/strikethrough.svg
Normal 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 |
1
lib/assets/icons/text-quote.svg
Normal 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 |
1
lib/assets/icons/underline.svg
Normal 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 |
1
lib/assets/icons/youtube.svg
Normal 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 |
@@ -1014,6 +1014,7 @@ a,
|
||||
video {
|
||||
aspect-ratio: 16 / 9;
|
||||
width: 850px;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
643
lib/components/base/MarkdownEditor.vue
Normal 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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
]
|
||||
@@ -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
@@ -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'}
|
||||
|
||||
@@ -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" }]
|
||||
|
||||