MOD-349 Contextual Uploads for MD Editor (#119)
* Migrate DropArea to composition * remove hardcoded button styling * let markdown editor call for image upload * allow for local testing in the docs * validate url on set * add chips to modal with correct defaults * update docs to show example url doesn't load * Bump version 0.6.4
This commit is contained in:
parent
c056c4e79e
commit
79bdea0441
@ -1,8 +1,14 @@
|
||||
# Drop Area
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
|
||||
const files = ref([])
|
||||
</script>
|
||||
|
||||
<DemoContainer>
|
||||
<InfoIcon /> Click to choose a file or drag one onto this page
|
||||
<DropArea accept="*" />
|
||||
<DropArea accept="*" @change="files">
|
||||
<InfoIcon /> Click to choose a file or drag one onto this page
|
||||
</DropArea>
|
||||
</DemoContainer>
|
||||
|
||||
```vue
|
||||
|
||||
@ -1,8 +1,14 @@
|
||||
# Markdown Editor
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
const description = ref(null)
|
||||
const description2 = ref(null)
|
||||
|
||||
const description = ref(null);
|
||||
const description2 = ref(null);
|
||||
const description3 = ref(null);
|
||||
|
||||
const onImageUpload = (file) => {
|
||||
return URL.createObjectURL(file).replace("blob:", "");
|
||||
};
|
||||
</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.
|
||||
@ -21,9 +27,30 @@ const description = ref(null)
|
||||
<MarkdownEditor v-model="description" />
|
||||
```
|
||||
|
||||
## With image upload
|
||||
<DemoContainer>
|
||||
<MarkdownEditor v-model="description2" :on-image-upload="onImageUpload" />
|
||||
</DemoContainer>
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
const description = ref(null)
|
||||
|
||||
// Return a URL to the image for the editor to consume
|
||||
const onImageUpload = (file: File): string => {
|
||||
// Upload the file to your server and return a URL
|
||||
// This example url will not work bc of proxy
|
||||
return URL.createObjectURL(file).replace("blob:", "");
|
||||
};
|
||||
</script>
|
||||
|
||||
<MarkdownEditor v-model="description" :on-image-upload="onImageUpload" />
|
||||
```
|
||||
|
||||
## Without heading buttons
|
||||
<DemoContainer>
|
||||
<MarkdownEditor v-model="description2" :heading-buttons="false" />
|
||||
<MarkdownEditor v-model="description3" :heading-buttons="false" />
|
||||
</DemoContainer>
|
||||
|
||||
```vue
|
||||
|
||||
@ -26,7 +26,7 @@ const inputText = ref(null)
|
||||
type="text"
|
||||
placeholder="Text input"
|
||||
/>
|
||||
<Button @click="() => inputText = ''">
|
||||
<Button class="r-btn" @click="() => inputText = ''">
|
||||
<XIcon/>
|
||||
</Button>
|
||||
</div>
|
||||
@ -65,7 +65,7 @@ const inputText = ref(null)
|
||||
type="text"
|
||||
placeholder="Text input"
|
||||
/>
|
||||
<Button @click="() => inputText = ''">
|
||||
<Button class="r-btn" @click="() => inputText = ''">
|
||||
<XIcon/>
|
||||
</Button>
|
||||
</div>
|
||||
@ -92,7 +92,7 @@ const value = ref(null)
|
||||
type="text"
|
||||
placeholder="Text input"
|
||||
/>
|
||||
<Button @click="() => inputText = ''">
|
||||
<Button class="r-btn" @click="() => inputText = ''">
|
||||
<XIcon/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -47,6 +47,7 @@ input[type='url'],
|
||||
input[type='number'],
|
||||
input[type='password'],
|
||||
textarea,
|
||||
.input-text-inherit,
|
||||
.cm-content {
|
||||
border-radius: var(--radius-md);
|
||||
box-sizing: border-box;
|
||||
@ -146,7 +147,7 @@ input[type='number'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.btn {
|
||||
.r-btn {
|
||||
@extend .transparent, .icon-only;
|
||||
|
||||
position: absolute;
|
||||
|
||||
@ -1,65 +1,72 @@
|
||||
<template>
|
||||
<div
|
||||
ref="drop_area"
|
||||
class="drop-area"
|
||||
@drop.stop.prevent="
|
||||
(event) => {
|
||||
$refs.drop_area.style.visibility = 'hidden'
|
||||
if (event.dataTransfer && event.dataTransfer.files && fileAllowed) {
|
||||
$emit('change', event.dataTransfer.files)
|
||||
}
|
||||
}
|
||||
"
|
||||
@dragenter.prevent="allowDrag"
|
||||
@dragover.prevent="allowDrag"
|
||||
@dragleave.prevent="$refs.drop_area.style.visibility = 'hidden'"
|
||||
/>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
ref="dropAreaRef"
|
||||
class="drop-area"
|
||||
@drop.stop.prevent="handleDrop"
|
||||
@dragenter.prevent="allowDrag"
|
||||
@dragover.prevent="allowDrag"
|
||||
@dragleave.prevent="hideDropArea"
|
||||
/>
|
||||
</Teleport>
|
||||
<slot />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from 'vue'
|
||||
<script setup lang="ts">
|
||||
import { defineProps, defineEmits, ref, onMounted } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
accept: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
emits: ['change'],
|
||||
data() {
|
||||
return {
|
||||
fileAllowed: false,
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
accept: string
|
||||
}>(),
|
||||
{
|
||||
accept: '*',
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
const dropAreaRef = ref<HTMLDivElement>()
|
||||
const fileAllowed = ref(false)
|
||||
|
||||
const hideDropArea = () => {
|
||||
if (dropAreaRef.value) {
|
||||
dropAreaRef.value.style.visibility = 'hidden'
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
hideDropArea()
|
||||
if (event.dataTransfer && event.dataTransfer.files && fileAllowed.value) {
|
||||
emit('change', event.dataTransfer.files)
|
||||
}
|
||||
}
|
||||
|
||||
const allowDrag = (event: DragEvent) => {
|
||||
const file = event.dataTransfer?.items[0]
|
||||
if (
|
||||
file &&
|
||||
props.accept
|
||||
.split(',')
|
||||
.reduce((acc, t) => acc || file.type.startsWith(t) || file.type === t || t === '*', false)
|
||||
) {
|
||||
fileAllowed.value = true
|
||||
event.dataTransfer.dropEffect = 'copy'
|
||||
event.preventDefault()
|
||||
if (dropAreaRef.value) {
|
||||
dropAreaRef.value.style.visibility = 'visible'
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
document.addEventListener('dragenter', this.allowDrag)
|
||||
},
|
||||
methods: {
|
||||
allowDrag(event) {
|
||||
const file = event.dataTransfer?.items[0]
|
||||
if (
|
||||
file &&
|
||||
this.accept
|
||||
.split(',')
|
||||
.reduce((acc, t) => acc || file.type.startsWith(t) || file.type === t || t === '*', false)
|
||||
) {
|
||||
this.fileAllowed = true
|
||||
event.dataTransfer.dropEffect = 'copy'
|
||||
event.preventDefault()
|
||||
if (this.$refs.drop_area) {
|
||||
this.$refs.drop_area.style.visibility = 'visible'
|
||||
}
|
||||
} else {
|
||||
this.fileAllowed = false
|
||||
if (this.$refs.drop_area) {
|
||||
this.$refs.drop_area.style.visibility = 'hidden'
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
} else {
|
||||
fileAllowed.value = false
|
||||
hideDropArea()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('dragenter', allowDrag)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.drop-area {
|
||||
position: fixed;
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
<div class="iconified-input">
|
||||
<AlignLeftIcon />
|
||||
<input id="insert-link-label" v-model="linkText" type="text" placeholder="Enter label..." />
|
||||
<Button @click="() => (linkText = '')">
|
||||
<Button class="r-btn" @click="() => (linkText = '')">
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
@ -23,7 +23,7 @@
|
||||
placeholder="Enter the link's URL..."
|
||||
@input="validateURL"
|
||||
/>
|
||||
<Button @click="() => (linkUrl = '')">
|
||||
<Button class="r-btn" @click="() => (linkUrl = '')">
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
@ -74,14 +74,31 @@
|
||||
type="text"
|
||||
placeholder="Describe the image..."
|
||||
/>
|
||||
<Button @click="() => (linkText = '')">
|
||||
<Button class="r-btn" @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">
|
||||
<div v-if="props.onImageUpload" class="image-strategy-chips">
|
||||
<Chips v-model="imageUploadOption" :items="['upload', 'link']" />
|
||||
</div>
|
||||
<div
|
||||
v-if="props.onImageUpload && imageUploadOption === 'upload'"
|
||||
class="iconified-input btn-input-alternative"
|
||||
>
|
||||
<FileInput
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
prompt="Upload an image"
|
||||
class="btn"
|
||||
should-always-reset
|
||||
@change="handleImageUpload"
|
||||
>
|
||||
<UploadIcon />
|
||||
</FileInput>
|
||||
</div>
|
||||
<div v-if="!props.onImageUpload || imageUploadOption === 'link'" class="iconified-input">
|
||||
<ImageIcon />
|
||||
<input
|
||||
id="insert-link-url"
|
||||
@ -90,7 +107,7 @@
|
||||
placeholder="Enter the image URL..."
|
||||
@input="validateURL"
|
||||
/>
|
||||
<Button @click="() => (linkUrl = '')">
|
||||
<Button class="r-btn" @click="() => (linkUrl = '')">
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
@ -141,7 +158,7 @@
|
||||
placeholder="Enter YouTube video URL"
|
||||
@input="validateURL"
|
||||
/>
|
||||
<Button @click="() => (linkUrl = '')">
|
||||
<Button class="r-btn" @click="() => (linkUrl = '')">
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
@ -200,7 +217,7 @@
|
||||
</template>
|
||||
</div>
|
||||
<div class="preview">
|
||||
<Toggle id="preview" v-model="previewMode" />
|
||||
<Toggle id="preview" v-model="previewMode" :checked="previewMode" />
|
||||
<label class="label" for="preview"> Preview </label>
|
||||
</div>
|
||||
</div>
|
||||
@ -249,24 +266,27 @@ import {
|
||||
Button,
|
||||
Modal,
|
||||
Toggle,
|
||||
FileInput,
|
||||
UploadIcon,
|
||||
Chips,
|
||||
} 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 props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
disabled: boolean
|
||||
headingButtons: boolean
|
||||
onImageUpload?: (file: File) => Promise<string>
|
||||
}>(),
|
||||
{
|
||||
modelValue: '',
|
||||
disabled: false,
|
||||
headingButtons: true,
|
||||
onImageUpload: undefined,
|
||||
}
|
||||
)
|
||||
|
||||
const editorRef = ref<HTMLDivElement>()
|
||||
let editor: EditorView | null = null
|
||||
@ -503,6 +523,22 @@ const linkMarkdown = computed(() => {
|
||||
return ''
|
||||
})
|
||||
|
||||
const handleImageUpload = async (files: FileList) => {
|
||||
if (props.onImageUpload) {
|
||||
const file = files[0]
|
||||
if (file) {
|
||||
try {
|
||||
const url = await props.onImageUpload(file)
|
||||
linkUrl.value = url
|
||||
validateURL()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const imageUploadOption = ref<string>('upload')
|
||||
const imageMarkdown = computed(() => (linkMarkdown.value.length ? `!${linkMarkdown.value}` : ''))
|
||||
|
||||
const youtubeRegex =
|
||||
@ -528,6 +564,7 @@ function openLinkModal() {
|
||||
}
|
||||
|
||||
function openImageModal() {
|
||||
linkValidationErrorMessage.value = undefined
|
||||
linkText.value = ''
|
||||
linkUrl.value = ''
|
||||
imageModal.value?.show()
|
||||
@ -646,4 +683,30 @@ function openVideoModal() {
|
||||
margin-top: var(--gap-lg);
|
||||
}
|
||||
}
|
||||
|
||||
.image-strategy-chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--gap-xs);
|
||||
padding-bottom: var(--gap-md);
|
||||
}
|
||||
|
||||
.btn-input-alternative {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--gap-xs);
|
||||
padding-bottom: var(--gap-xs);
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding-left: 2.5rem;
|
||||
min-height: 4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -129,7 +129,7 @@ defineExpose({
|
||||
<div v-if="link" class="iconified-input">
|
||||
<LinkIcon />
|
||||
<input type="text" :value="url" readonly />
|
||||
<Button v-tooltip="'Copy Text'" @click="copyText">
|
||||
<Button v-tooltip="'Copy Text'" class="r-btn" @click="copyText">
|
||||
<ClipboardCopyIcon />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -26,7 +26,7 @@
|
||||
@focusout="onBlur"
|
||||
@keydown.enter.prevent="$emit('enter')"
|
||||
/>
|
||||
<Button :disabled="disabled" @click="() => $emit('update:modelValue', '')">
|
||||
<Button :disabled="disabled" class="r-btn" @click="() => $emit('update:modelValue', '')">
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "omorphia",
|
||||
"type": "module",
|
||||
"version": "0.6.3",
|
||||
"version": "0.6.4",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user