tiptap editor #2222

Merged
konrad merged 66 commits from dpschen/frontend:feature/feat-tiptap-editor into main 2023-10-22 13:49:01 +00:00
3 changed files with 90 additions and 42 deletions
Showing only changes of commit 05bf7ccf0b - Show all commits

View File

@ -151,7 +151,7 @@
</div> </div>
<div class="editor-toolbar__segment"> <div class="editor-toolbar__segment">
<BaseButton class="editor-toolbar__button" @click="addImage" title="Add image from URL"> <BaseButton class="editor-toolbar__button" @click="uploadInputRef?.click()" title="Add image">
<span class="icon"> <span class="icon">
<icon icon="fa-image" /> <icon icon="fa-image" />
</span> </span>
@ -369,38 +369,64 @@
</BaseButton> </BaseButton>
</div> </div>
</div> </div>
<input type="file" ref="uploadInputRef" class="is-hidden" @change="addImage"/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {ref, type PropType} from 'vue' import {ref} from 'vue'
import {Editor} from '@tiptap/vue-3' import {Editor} from '@tiptap/vue-3'
import BaseButton from '@/components/base/BaseButton.vue' import BaseButton from '@/components/base/BaseButton.vue'
const props = defineProps({ export type UploadCallback = (files: File[] | FileList) => Promise<string[]>
editor: {
default: null, const {
type: Editor as PropType<Editor>, editor = null,
}, uploadCallback,
}) } = defineProps<{
editor: Editor,
uploadCallback?: UploadCallback,
}>()
const emit = defineEmits(['imageAdded'])
const tableMode = ref(false) const tableMode = ref(false)
const uploadInputRef = ref<HTMLInputElement | null>(null)
function toggleTableMode() { function toggleTableMode() {
tableMode.value = !tableMode.value tableMode.value = !tableMode.value
} }
function addImage() { function addImage() {
if (typeof uploadCallback !== 'undefined') {
const files = uploadInputRef.value?.files
if (!files || files.length === 0) {
return
}
uploadCallback(files).then(urls => {
urls.forEach(url => {
editor?.chain().focus().setImage({ src: url }).run()
})
emit('imageAdded')
})
return
}
const url = window.prompt('URL') const url = window.prompt('URL')
if (url) { if (url) {
props.editor?.chain().focus().setImage({ src: url }).run() editor?.chain().focus().setImage({ src: url }).run()
emit('imageAdded')
} }
} }
function setLink() { function setLink() {
const previousUrl = props.editor.getAttributes('link').href const previousUrl = editor.getAttributes('link').href
const url = window.prompt('URL', previousUrl) const url = window.prompt('URL', previousUrl)
// cancelled // cancelled
@ -410,13 +436,13 @@ function setLink() {
// empty // empty
if (url === '') { if (url === '') {
props.editor.chain().focus().extendMarkRange('link').unsetLink().run() editor.chain().focus().extendMarkRange('link').unsetLink().run()
return return
} }
// update link // update link
props.editor editor
.chain() .chain()
.focus() .focus()
.extendMarkRange('link') .extendMarkRange('link')

View File

@ -1,6 +1,11 @@
<template> <template>
<div class="tiptap"> <div class="tiptap">
<EditorToolbar v-if="editor" :editor="editor" /> <EditorToolbar
v-if="editor"
:editor="editor"
:upload-callback="uploadCallback"
@image-added="bubbleChanges"
/>
<editor-content class="tiptap__editor" :editor="editor" /> <editor-content class="tiptap__editor" :editor="editor" />
</div> </div>
</template> </template>
@ -42,6 +47,7 @@ import {EditorContent, useEditor, VueNodeViewRenderer} from '@tiptap/vue-3'
import {lowlight} from 'lowlight' import {lowlight} from 'lowlight'
import CodeBlock from './CodeBlock.vue' import CodeBlock from './CodeBlock.vue'
import type {UploadCallback} from '@/components/base/EditorToolbar.vue'
// const CustomDocument = Document.extend({ // const CustomDocument = Document.extend({
// content: 'taskList', // content: 'taskList',
@ -72,34 +78,38 @@ const CustomTableCell = TableCell.extend({
}, },
}) })
const props = withDefaults(defineProps<{ const {
modelValue = '',
uploadCallback,
} = defineProps<{
modelValue?: string, modelValue?: string,
}>(), { uploadCallback?: UploadCallback,
modelValue: '', }>()
})
const emit = defineEmits(['update:modelValue', 'change']) const emit = defineEmits(['update:modelValue', 'change'])
const inputHTML = ref('') const inputHTML = ref('')
watch( watch(
() => props.modelValue, () => modelValue,
() => { () => {
if (!props.modelValue.startsWith(TIPTAP_TEXT_VALUE_PREFIX)) { if (!modelValue.startsWith(TIPTAP_TEXT_VALUE_PREFIX)) {
// convert Markdown to HTML // convert Markdown to HTML
return TIPTAP_TEXT_VALUE_PREFIX + marked.parse(props.modelValue) return TIPTAP_TEXT_VALUE_PREFIX + marked.parse(modelValue)
} }
return props.modelValue.replace(tiptapRegex, '') return modelValue.replace(tiptapRegex, '')
}, },
{ immediate: true }, { immediate: true },
) )
const debouncedInputHTML = refDebounced(inputHTML, 1000) const debouncedInputHTML = refDebounced(inputHTML, 1000)
watch(debouncedInputHTML, (value) => { watch(debouncedInputHTML, () => bubbleChanges())
emit('update:modelValue', TIPTAP_TEXT_VALUE_PREFIX + value)
emit('change', TIPTAP_TEXT_VALUE_PREFIX + value) // FIXME: remove this function bubbleChanges() {
}) emit('update:modelValue', TIPTAP_TEXT_VALUE_PREFIX + inputHTML.value)
emit('change', TIPTAP_TEXT_VALUE_PREFIX + inputHTML.value) // FIXME: remove this
}
const editor = useEditor({ const editor = useEditor({
content: inputHTML.value, content: inputHTML.value,
@ -122,7 +132,7 @@ const editor = useEditor({
// // start // // start
// Document, // Document,
// // Text, // // Text,
// Image, Image,
// // Tasks // // Tasks
// CustomDocument, // CustomDocument,

View File

@ -18,8 +18,7 @@
</h3> </h3>
<editor <editor
:is-edit-enabled="canWrite" :is-edit-enabled="canWrite"
:upload-callback="attachmentUpload" :upload-callback="uploadCallback"
:upload-enabled="true"
:placeholder="$t('task.description.placeholder')" :placeholder="$t('task.description.placeholder')"
:empty-text="$t('task.description.empty')" :empty-text="$t('task.description.empty')"
:show-save="true" :show-save="true"
@ -41,19 +40,17 @@ import type {ITask} from '@/modelTypes/ITask'
import {useTaskStore} from '@/stores/tasks' import {useTaskStore} from '@/stores/tasks'
import TaskModel from '@/models/task' import TaskModel from '@/models/task'
const props = defineProps({ type AttachmentUploadFunction = (file: File, onSuccess: (attachmentUrl: string) => void) => Promise<string>
modelValue: {
type: Object as PropType<ITask>, const {
required: true, modelValue,
}, attachmentUpload,
attachmentUpload: { canWrite,
required: true, } = defineProps<{
}, modelValue: ITask,
canWrite: { attachmentUpload: AttachmentUploadFunction,
type: Boolean, canWrite: boolean,
required: true, }>()
},
})
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
@ -67,7 +64,7 @@ const taskStore = useTaskStore()
const loading = computed(() => taskStore.isLoading) const loading = computed(() => taskStore.isLoading)
watch( watch(
props.modelValue, () => modelValue,
(value) => { (value) => {
task.value = value task.value = value
}, },
@ -106,5 +103,20 @@ async function save() {
saving.value = false saving.value = false
} }
} }
async function uploadCallback(files: File[] | FileList): (Promise<string[]>) {
const uploadPromises: Promise<string>[] = []
files.forEach((file: File) => {
const promise = new Promise<string>((resolve) => {
attachmentUpload(file, (uploadedFileUrl: string) => resolve(uploadedFileUrl))
})
uploadPromises.push(promise)
})
return await Promise.all(uploadPromises)
}
</script> </script>