feat(editor): move all editor related components into one folder
This commit is contained in:
@ -1,6 +1,6 @@
|
||||
import {createAsyncComponent} from '@/helpers/createAsyncComponent'
|
||||
|
||||
// const Editor = createAsyncComponent(() => import('@/components/input/editor.vue'))
|
||||
const TipTap = createAsyncComponent(() => import('@/components/base/TipTap.vue'))
|
||||
const TipTap = createAsyncComponent(() => import('@/components/input/editor/TipTap.vue'))
|
||||
|
||||
export default TipTap
|
||||
|
46
src/components/input/editor/CodeBlock.vue
Normal file
46
src/components/input/editor/CodeBlock.vue
Normal file
@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<node-view-wrapper class="code-block">
|
||||
<select contenteditable="false" v-model="selectedLanguage">
|
||||
<option :value="null">
|
||||
auto
|
||||
</option>
|
||||
<option disabled>
|
||||
—
|
||||
</option>
|
||||
<option v-for="(language, index) in languages" :value="language" :key="index">
|
||||
{{ language }}
|
||||
</option>
|
||||
</select>
|
||||
<pre><code><node-view-content /></code></pre>
|
||||
</node-view-wrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed} from 'vue'
|
||||
import {NodeViewContent, nodeViewProps, NodeViewWrapper} from '@tiptap/vue-3'
|
||||
|
||||
const props = defineProps(nodeViewProps)
|
||||
|
||||
const languages = ref(props.extension.options.lowlight.listLanguages())
|
||||
|
||||
const selectedLanguage = computed({
|
||||
get() {
|
||||
return props.node.attrs.language
|
||||
},
|
||||
set(language) {
|
||||
props.updateAttributes({ language })
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.code-block {
|
||||
position: relative;
|
||||
|
||||
select {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
514
src/components/input/editor/EditorToolbar.vue
Normal file
514
src/components/input/editor/EditorToolbar.vue
Normal file
@ -0,0 +1,514 @@
|
||||
<template>
|
||||
<div class="editor-toolbar">
|
||||
<div class="editor-toolbar__segment">
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
|
||||
:class="{ 'is-active': editor.isActive('heading', { level: 1 }) }"
|
||||
title="h1"
|
||||
>
|
||||
<span class="icon"> <icon :icon="['fa', 'fa-header']"/> </span>1
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
|
||||
:class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
|
||||
title="h2"
|
||||
>
|
||||
<span class="icon"> <icon :icon="['fa', 'fa-header']"/> </span>2
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
|
||||
:class="{ 'is-active': editor.isActive('heading', { level: 3 }) }"
|
||||
title="h3"
|
||||
>
|
||||
<span class="icon"> <icon :icon="['fa', 'fa-header']"/> </span>3
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleHeading({ level: 4 }).run()"
|
||||
:class="{ 'is-active': editor.isActive('heading', { level: 4 }) }"
|
||||
title="h4"
|
||||
>
|
||||
<span class="icon"> <icon :icon="['fa', 'fa-header']"/> </span>4
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleHeading({ level: 5 }).run()"
|
||||
:class="{ 'is-active': editor.isActive('heading', { level: 5 }) }"
|
||||
title="h5"
|
||||
>
|
||||
<span class="icon"> <icon :icon="['fa', 'fa-header']"/> </span>5
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleHeading({ level: 6 }).run()"
|
||||
:class="{ 'is-active': editor.isActive('heading', { level: 6 }) }"
|
||||
title="h6"
|
||||
>
|
||||
<span class="icon"> <icon :icon="['fa', 'fa-header']"/> </span>6
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="editor-toolbar__segment">
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleBold().run()"
|
||||
:class="{ 'is-active': editor.isActive('bold') }"
|
||||
title="bold"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-bold']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleItalic().run()"
|
||||
:class="{ 'is-active': editor.isActive('italic') }"
|
||||
title="italic"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-italic']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleStrike().run()"
|
||||
:class="{ 'is-active': editor.isActive('strike') }"
|
||||
title="strike"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-strikethrough']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="editor-toolbar__segment">
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleCode().run()"
|
||||
:class="{ 'is-active': editor.isActive('code') }"
|
||||
title="code"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-code']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleCodeBlock().run()"
|
||||
:class="{ 'is-active': editor.isActive('codeBlock') }"
|
||||
title="code block"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-code']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleBlockquote().run()"
|
||||
:class="{ 'is-active': editor.isActive('blockquote') }"
|
||||
title="quote"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-quote-right']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="editor-toolbar__segment">
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleBulletList().run()"
|
||||
:class="{ 'is-active': editor.isActive('bulletList') }"
|
||||
title="bullet list"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-list-ol']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleOrderedList().run()"
|
||||
:class="{ 'is-active': editor.isActive('orderedList') }"
|
||||
title="ordered list"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-list-ul']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleTaskList().run()"
|
||||
:class="{ 'is-active': editor.isActive('taskList') }"
|
||||
title="task list"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon icon="fa-list-check"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="editor-toolbar__segment">
|
||||
<BaseButton class="editor-toolbar__button" @click="uploadInputRef?.click()" title="Add image">
|
||||
<span class="icon">
|
||||
<icon icon="fa-image"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="editor-toolbar__segment">
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().unsetAllMarks().run()"
|
||||
>
|
||||
<icon icon="xmark"/>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().clearNodes().run()"
|
||||
>
|
||||
<icon icon="xmarks-lines"/>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="editor-toolbar__segment">
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="setLink"
|
||||
:class="{ 'is-active': editor.isActive('link') }"
|
||||
title="set link"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-link']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().unsetLink().run()"
|
||||
:disabled="!editor.isActive('link')"
|
||||
title="unset link"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-unlink']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().setParagraph().run()"
|
||||
:class="{ 'is-active': editor.isActive('paragraph') }"
|
||||
title="paragraph"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-paragraph']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().setHorizontalRule().run()"
|
||||
>
|
||||
<span class="editor-toolbar__pseudo-icon">
|
||||
-
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().setHardBreak().run()"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-arrow-turn-down']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="editor-toolbar__segment">
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().undo().run()"
|
||||
title="undo"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-undo']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().redo().run()"
|
||||
title="redo"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-redo']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="editor-toolbar__segment">
|
||||
<!-- table -->
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="toggleTableMode"
|
||||
:class="{ 'is-active': editor.isActive('table') }"
|
||||
title="table"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-table']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<div v-if="tableMode" class="editor-toolbar__table-buttons">
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||||
.run()
|
||||
"
|
||||
>
|
||||
{{ $t('input.editor.table.insert') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().addColumnBefore().run()"
|
||||
:disabled="!editor.can().addColumnBefore"
|
||||
>
|
||||
{{ $t('input.editor.table.addColumnBefore') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().addColumnAfter().run()"
|
||||
:disabled="!editor.can().addColumnAfter"
|
||||
>
|
||||
{{ $t('input.editor.table.addColumnAfter') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().deleteColumn().run()"
|
||||
:disabled="!editor.can().deleteColumn"
|
||||
>
|
||||
{{ $t('input.editor.table.deleteColumn') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().addRowBefore().run()"
|
||||
:disabled="!editor.can().addRowBefore"
|
||||
>
|
||||
{{ $t('input.editor.table.addRowBefore') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().addRowAfter().run()"
|
||||
:disabled="!editor.can().addRowAfter"
|
||||
>
|
||||
{{ $t('input.editor.table.addRowAfter') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().deleteRow().run()"
|
||||
:disabled="!editor.can().deleteRow"
|
||||
>
|
||||
{{ $t('input.editor.table.deleteRow') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().deleteTable().run()"
|
||||
:disabled="!editor.can().deleteTable"
|
||||
>
|
||||
{{ $t('input.editor.table.deleteTable') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().mergeCells().run()"
|
||||
:disabled="!editor.can().mergeCells"
|
||||
>
|
||||
{{ $t('input.editor.table.mergeCells') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().splitCell().run()"
|
||||
:disabled="!editor.can().splitCell"
|
||||
>
|
||||
{{ $t('input.editor.table.splitCell') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleHeaderColumn().run()"
|
||||
:disabled="!editor.can().toggleHeaderColumn"
|
||||
>
|
||||
{{ $t('input.editor.table.toggleHeaderColumn') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleHeaderRow().run()"
|
||||
:disabled="!editor.can().toggleHeaderRow"
|
||||
>
|
||||
{{ $t('input.editor.table.toggleHeaderRow') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleHeaderCell().run()"
|
||||
:disabled="!editor.can().toggleHeaderCell"
|
||||
>
|
||||
{{ $t('input.editor.table.toggleHeaderCell') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().mergeOrSplit().run()"
|
||||
:disabled="!editor.can().mergeOrSplit"
|
||||
>
|
||||
{{ $t('input.editor.table.mergeOrSplit') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().fixTables().run()"
|
||||
:disabled="!editor.can().fixTables"
|
||||
>
|
||||
{{ $t('input.editor.table.fixTables') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" ref="uploadInputRef" class="is-hidden" @change="addImage"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
import {Editor} from '@tiptap/vue-3'
|
||||
import type {UploadCallback} from './types'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
const {
|
||||
editor = null,
|
||||
uploadCallback,
|
||||
} = defineProps<{
|
||||
editor: Editor,
|
||||
uploadCallback?: UploadCallback,
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['imageAdded'])
|
||||
|
||||
const tableMode = ref(false)
|
||||
const uploadInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function toggleTableMode() {
|
||||
tableMode.value = !tableMode.value
|
||||
}
|
||||
|
||||
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')
|
||||
|
||||
if (url) {
|
||||
editor?.chain().focus().setImage({src: url}).run()
|
||||
emit('imageAdded')
|
||||
}
|
||||
}
|
||||
|
||||
function setLink() {
|
||||
const previousUrl = editor.getAttributes('link').href
|
||||
const url = window.prompt('URL', previousUrl)
|
||||
|
||||
// cancelled
|
||||
if (url === null) {
|
||||
return
|
||||
}
|
||||
|
||||
// empty
|
||||
if (url === '') {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// update link
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.extendMarkRange('link')
|
||||
.setLink({href: url, target: '_blank'})
|
||||
.run()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.editor-toolbar {
|
||||
background: var(--grey-50);
|
||||
border: 1px solid var(--grey-200);
|
||||
border-bottom: none;
|
||||
// position: relative;
|
||||
user-select: none;
|
||||
padding: 9px 10px;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
> * + * {
|
||||
// .editor-toolbar i.separator {
|
||||
border--left-color: var(--grey-200) !important;
|
||||
// }
|
||||
// .editor-toolbar i.separator {
|
||||
// display: inline-block;
|
||||
// width: 0;
|
||||
border-left: 1px solid var(--grey-200);
|
||||
// border-right: 1px solid #fff;
|
||||
// color: transparent;
|
||||
// text-indent: -10px;
|
||||
margin-left: 6px;
|
||||
padding-left: 6px;
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
.editor-toolbar__button {
|
||||
color: var(--grey-700);
|
||||
min-width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid transparent;
|
||||
|
||||
&:hover {
|
||||
background: var(--grey-200);
|
||||
border-color: var(--grey-300);
|
||||
}
|
||||
}
|
||||
|
||||
.editor-toolbar__pseudo-icon {
|
||||
padding: 0 .5rem;
|
||||
font-weight: bold;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.editor-toolbar__table-buttons {
|
||||
margin-top: .5rem;
|
||||
|
||||
> .editor-toolbar__button {
|
||||
margin-right: .5rem;
|
||||
margin-bottom: .5rem;
|
||||
padding: 0 .25rem;
|
||||
border: 1px solid var(--grey-400);
|
||||
font-size: .75rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
505
src/components/input/editor/TipTap.vue
Normal file
505
src/components/input/editor/TipTap.vue
Normal file
@ -0,0 +1,505 @@
|
||||
<template>
|
||||
<div class="tiptap">
|
||||
<EditorToolbar
|
||||
v-if="editor"
|
||||
:editor="editor"
|
||||
:upload-callback="uploadCallback"
|
||||
@image-added="onImageAdded"
|
||||
/>
|
||||
<editor-content
|
||||
class="tiptap__editor"
|
||||
:editor="editor"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export const TIPTAP_TEXT_VALUE_PREFIX = '<!-- VIKUNJA TIPTAP -->\n'
|
||||
const tiptapRegex = new RegExp(`${TIPTAP_TEXT_VALUE_PREFIX}`, 's')
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, watch, computed, onBeforeUnmount, type PropType, nextTick} from 'vue'
|
||||
import {marked} from 'marked'
|
||||
import {refDebounced} from '@vueuse/core'
|
||||
|
||||
import EditorToolbar from './EditorToolbar.vue'
|
||||
|
||||
import Link from '@tiptap/extension-link'
|
||||
|
||||
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
|
||||
import Table from '@tiptap/extension-table'
|
||||
import TableCell from '@tiptap/extension-table-cell'
|
||||
import TableHeader from '@tiptap/extension-table-header'
|
||||
import TableRow from '@tiptap/extension-table-row'
|
||||
import Highlight from '@tiptap/extension-highlight'
|
||||
import Typography from '@tiptap/extension-typography'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Image from '@tiptap/extension-image'
|
||||
// import Text from '@tiptap/extension-text'
|
||||
|
||||
import TaskItem from '@tiptap/extension-task-item'
|
||||
import TaskList from '@tiptap/extension-task-list'
|
||||
|
||||
import CharacterCount from '@tiptap/extension-character-count'
|
||||
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import {EditorContent, useEditor, VueNodeViewRenderer} from '@tiptap/vue-3'
|
||||
|
||||
// load all highlight.js languages
|
||||
import {lowlight} from 'lowlight'
|
||||
|
||||
import CodeBlock from './CodeBlock.vue'
|
||||
import type {UploadCallback} from './types'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import type {IAttachment} from '@/modelTypes/IAttachment'
|
||||
import AttachmentModel from '@/models/attachment'
|
||||
import AttachmentService from '@/services/attachment'
|
||||
|
||||
// const CustomDocument = Document.extend({
|
||||
// content: 'taskList',
|
||||
// })
|
||||
|
||||
const CustomTaskItem = TaskItem.configure({
|
||||
nested: true,
|
||||
})
|
||||
|
||||
const CustomTableCell = TableCell.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
// extend the existing attributes …
|
||||
...this.parent?.(),
|
||||
|
||||
// and add a new one …
|
||||
backgroundColor: {
|
||||
default: null,
|
||||
parseHTML: (element: HTMLElement) => element.getAttribute('data-background-color'),
|
||||
renderHTML: (attributes) => {
|
||||
return {
|
||||
'data-background-color': attributes.backgroundColor,
|
||||
style: `background-color: ${attributes.backgroundColor}`,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
modelValue,
|
||||
uploadCallback,
|
||||
} = defineProps<{
|
||||
modelValue: string,
|
||||
uploadCallback?: UploadCallback,
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const inputHTML = ref('')
|
||||
watch(
|
||||
() => modelValue,
|
||||
() => {
|
||||
if (modelValue === '') {
|
||||
return
|
||||
}
|
||||
|
||||
if (!modelValue.startsWith(TIPTAP_TEXT_VALUE_PREFIX)) {
|
||||
// convert Markdown to HTML
|
||||
inputHTML.value = TIPTAP_TEXT_VALUE_PREFIX + marked.parse(modelValue)
|
||||
nextTick(() => loadImages())
|
||||
return
|
||||
}
|
||||
|
||||
inputHTML.value = modelValue.replace(tiptapRegex, '')
|
||||
nextTick(() => loadImages())
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
function onImageAdded() {
|
||||
bubbleChanges()
|
||||
loadImages()
|
||||
}
|
||||
|
||||
type CacheKey = `${ITask['id']}-${IAttachment['id']}`
|
||||
const loadedAttachments = ref<{ [key: CacheKey]: string }>({})
|
||||
|
||||
function loadImages() {
|
||||
const attachmentImage = document.querySelectorAll<HTMLImageElement>('.tiptap__editor img')
|
||||
const attachmentService = new AttachmentService()
|
||||
console.log('load images', attachmentImage)
|
||||
if (attachmentImage) {
|
||||
Array.from(attachmentImage).forEach(async (img) => {
|
||||
if (!img.src.startsWith(window.API_URL)) {
|
||||
return
|
||||
}
|
||||
// The url is something like /tasks/<id>/attachments/<id>
|
||||
const parts = img.src.slice(window.API_URL.length + 1).split('/')
|
||||
const taskId = Number(parts[1])
|
||||
const attachmentId = Number(parts[3])
|
||||
const cacheKey: CacheKey = `${taskId}-${attachmentId}`
|
||||
|
||||
if (typeof loadedAttachments.value[cacheKey] !== 'undefined') {
|
||||
img.src = loadedAttachments.value[cacheKey]
|
||||
return
|
||||
}
|
||||
|
||||
const attachment = new AttachmentModel({taskId: taskId, id: attachmentId})
|
||||
|
||||
const url = await attachmentService.getBlobUrl(attachment)
|
||||
img.src = url
|
||||
loadedAttachments.value[cacheKey] = url
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const debouncedInputHTML = refDebounced(inputHTML, 1000)
|
||||
|
||||
watch(debouncedInputHTML, () => bubbleChanges())
|
||||
|
||||
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({
|
||||
content: inputHTML.value,
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Highlight,
|
||||
Typography,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
validate: (href: string) => /^https?:\/\//.test(href),
|
||||
}),
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
}),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
// Custom TableCell with backgroundColor attribute
|
||||
CustomTableCell,
|
||||
|
||||
// // start
|
||||
// Document,
|
||||
// // Text,
|
||||
Image,
|
||||
|
||||
// // Tasks
|
||||
// CustomDocument,
|
||||
TaskList,
|
||||
CustomTaskItem,
|
||||
|
||||
// // character count
|
||||
// CharacterCount,
|
||||
|
||||
// CodeBlockLowlight.extend({
|
||||
// addNodeView() {
|
||||
// return VueNodeViewRenderer(CodeBlock)
|
||||
// },
|
||||
// }).configure({ lowlight }),
|
||||
],
|
||||
onUpdate: () => {
|
||||
// HTML
|
||||
inputHTML.value = editor.value!.getHTML()
|
||||
|
||||
// JSON
|
||||
// this.$emit('update:modelValue', this.editor.getJSON())
|
||||
},
|
||||
})
|
||||
|
||||
watch(inputHTML, (value) => {
|
||||
if (!editor.value) return
|
||||
// HTML
|
||||
const isSame = editor.value.getHTML() === value
|
||||
|
||||
// JSON
|
||||
// const isSame = JSON.stringify(editor.value.getJSON()) === JSON.stringify(value)
|
||||
|
||||
if (isSame) {
|
||||
return
|
||||
}
|
||||
|
||||
editor.value.commands.setContent(value, false)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => editor.value?.destroy())
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.tiptap__editor {
|
||||
// box-sizing: border-box;
|
||||
// height: auto;
|
||||
min-height: 150px;
|
||||
border: 1px solid #ddd;
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
//padding: 10px;
|
||||
// font: inherit;
|
||||
// z-index: 0;
|
||||
// word-wrap: break-word;
|
||||
|
||||
border: 1px solid var(--grey-200) !important;
|
||||
background: var(--white);
|
||||
}
|
||||
|
||||
/* Basic editor styles */
|
||||
.ProseMirror {
|
||||
padding: .5rem;
|
||||
|
||||
> * + * {
|
||||
margin-top: 0.75em;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #68cef8;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: rgba(#616161, 0.1);
|
||||
color: #616161;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #0d0d0d;
|
||||
color: #fff;
|
||||
font-family: 'JetBrainsMono', monospace;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
code {
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
background: none;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #0d0d0d;
|
||||
color: #fff;
|
||||
font-family: 'JetBrainsMono', monospace;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
code {
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
background: none;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: #616161;
|
||||
}
|
||||
|
||||
.hljs-variable,
|
||||
.hljs-template-variable,
|
||||
.hljs-attribute,
|
||||
.hljs-tag,
|
||||
.hljs-name,
|
||||
.hljs-regexp,
|
||||
.hljs-link,
|
||||
.hljs-name,
|
||||
.hljs-selector-id,
|
||||
.hljs-selector-class {
|
||||
color: #f98181;
|
||||
}
|
||||
|
||||
.hljs-number,
|
||||
.hljs-meta,
|
||||
.hljs-built_in,
|
||||
.hljs-builtin-name,
|
||||
.hljs-literal,
|
||||
.hljs-type,
|
||||
.hljs-params {
|
||||
color: #fbbc88;
|
||||
}
|
||||
|
||||
.hljs-string,
|
||||
.hljs-symbol,
|
||||
.hljs-bullet {
|
||||
color: #b9f18d;
|
||||
}
|
||||
|
||||
.hljs-title,
|
||||
.hljs-section {
|
||||
color: #faf594;
|
||||
}
|
||||
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag {
|
||||
color: #70cff8;
|
||||
}
|
||||
|
||||
.hljs-emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
|
||||
&.ProseMirror-selectednode {
|
||||
outline: 3px solid #68cef8;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
padding-left: 1rem;
|
||||
border-left: 2px solid rgba(#0d0d0d, 0.1);
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 2px solid rgba(#0d0d0d, 0.1);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
/* Table-specific styling */
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
|
||||
td,
|
||||
th {
|
||||
min-width: 1em;
|
||||
border: 2px solid #ced4da;
|
||||
padding: 3px 5px;
|
||||
vertical-align: top;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
|
||||
> * {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
background-color: #f1f3f5;
|
||||
}
|
||||
|
||||
.selectedCell:after {
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
content: '';
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: rgba(200, 200, 255, 0.4);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.column-resize-handle {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
top: 0;
|
||||
bottom: -2px;
|
||||
width: 4px;
|
||||
background-color: #adf;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Lists
|
||||
ul {
|
||||
margin-left: .5rem;
|
||||
margin-top: 0 !important;
|
||||
|
||||
li {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tableWrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.resize-cursor {
|
||||
cursor: ew-resize;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
// tasklist
|
||||
ul[data-type='taskList'] {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin-left: 0;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
|
||||
> label {
|
||||
flex: 0 0 auto;
|
||||
margin-right: 0.5rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
> div {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
// character count
|
||||
.character-count {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #68cef8;
|
||||
|
||||
&--warning {
|
||||
color: #fb5151;
|
||||
}
|
||||
|
||||
&__graph {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
&__text {
|
||||
color: #868e96;
|
||||
}
|
||||
}
|
||||
</style>
|
1
src/components/input/editor/types.ts
Normal file
1
src/components/input/editor/types.ts
Normal file
@ -0,0 +1 @@
|
||||
export type UploadCallback = (files: File[] | FileList) => Promise<string[]>
|
Reference in New Issue
Block a user