wip: tiptap editor
This commit is contained in:
parent
01c2acdf34
commit
24b8915983
27
package.json
27
package.json
@ -50,6 +50,32 @@
|
|||||||
"@fortawesome/free-solid-svg-icons": "6.4.2",
|
"@fortawesome/free-solid-svg-icons": "6.4.2",
|
||||||
"@fortawesome/vue-fontawesome": "3.0.3",
|
"@fortawesome/vue-fontawesome": "3.0.3",
|
||||||
"@github/hotkey": "2.1.1",
|
"@github/hotkey": "2.1.1",
|
||||||
|
"@infectoone/vue-ganttastic": "2.1.2",
|
||||||
|
"@kyvg/vue3-notification": "2.6.1",
|
||||||
|
"@sentry/tracing": "7.19.0",
|
||||||
|
"@sentry/vue": "7.19.0",
|
||||||
|
"@tiptap/extension-character-count": "2.0.0-beta.202",
|
||||||
|
"@tiptap/extension-code-block-lowlight": "2.0.0-beta.202",
|
||||||
|
"@tiptap/extension-document": "2.0.0-beta.202",
|
||||||
|
"@tiptap/extension-highlight": "2.0.0-beta.202",
|
||||||
|
"@tiptap/extension-image": "2.0.0-beta.202",
|
||||||
|
"@tiptap/extension-link": "2.0.0-beta.202",
|
||||||
|
"@tiptap/extension-table": "2.0.0-beta.202",
|
||||||
|
"@tiptap/extension-table-cell": "2.0.0-beta.202",
|
||||||
|
"@tiptap/extension-table-header": "2.0.0-beta.202",
|
||||||
|
"@tiptap/extension-table-row": "2.0.0-beta.202",
|
||||||
|
"@tiptap/extension-task-item": "2.0.0-beta.202",
|
||||||
|
"@tiptap/extension-task-list": "2.0.0-beta.202",
|
||||||
|
"@tiptap/extension-typography": "2.0.0-beta.202",
|
||||||
|
"@tiptap/starter-kit": "2.0.0-beta.202",
|
||||||
|
"@tiptap/vue-3": "2.0.0-beta.202",
|
||||||
|
"@types/is-touch-device": "1.0.0",
|
||||||
|
"@types/lodash.clonedeep": "4.5.7",
|
||||||
|
"@types/sortablejs": "1.15.0",
|
||||||
|
"@vueuse/core": "9.5.0",
|
||||||
|
"@vueuse/router": "9.5.0",
|
||||||
|
"axios": "0.27.2",
|
||||||
|
"blurhash": "2.0.4",
|
||||||
"@infectoone/vue-ganttastic": "2.2.0",
|
"@infectoone/vue-ganttastic": "2.2.0",
|
||||||
"@intlify/unplugin-vue-i18n": "1.4.0",
|
"@intlify/unplugin-vue-i18n": "1.4.0",
|
||||||
"@kyvg/vue3-notification": "3.0.2",
|
"@kyvg/vue3-notification": "3.0.2",
|
||||||
@ -74,6 +100,7 @@
|
|||||||
"is-touch-device": "1.0.1",
|
"is-touch-device": "1.0.1",
|
||||||
"klona": "2.0.6",
|
"klona": "2.0.6",
|
||||||
"lodash.debounce": "4.0.8",
|
"lodash.debounce": "4.0.8",
|
||||||
|
"lowlight": "2.7.0",
|
||||||
"marked": "9.1.2",
|
"marked": "9.1.2",
|
||||||
"pinia": "2.1.7",
|
"pinia": "2.1.7",
|
||||||
"register-service-worker": "1.7.2",
|
"register-service-worker": "1.7.2",
|
||||||
|
46
src/components/base/CodeBlock.vue
Normal file
46
src/components/base/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>
|
495
src/components/base/EditorToolbar.vue
Normal file
495
src/components/base/EditorToolbar.vue
Normal file
@ -0,0 +1,495 @@
|
|||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-toolbar__segment">
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
@click="editor.chain().focus().unsetAllMarks().run()"
|
||||||
|
>
|
||||||
|
clear marks
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
@click="editor.chain().focus().clearNodes().run()"
|
||||||
|
>
|
||||||
|
clear nodes
|
||||||
|
</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().toggleTaskList().run()"
|
||||||
|
:class="{ 'is-active': editor.isActive('taskList') }"
|
||||||
|
title="task list"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<icon :icon="['fa', ' fa-list-check']" />
|
||||||
|
</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()"
|
||||||
|
>
|
||||||
|
horizontal rule
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
@click="editor.chain().focus().setHardBreak().run()"
|
||||||
|
>
|
||||||
|
hard break
|
||||||
|
</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">
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
@click="
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||||||
|
.run()
|
||||||
|
"
|
||||||
|
>
|
||||||
|
insertTable
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
@click="editor.chain().focus().addColumnBefore().run()"
|
||||||
|
:disabled="!editor.can().addColumnBefore()"
|
||||||
|
>
|
||||||
|
addColumnBefore
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
@click="editor.chain().focus().addColumnAfter().run()"
|
||||||
|
:disabled="!editor.can().addColumnAfter()"
|
||||||
|
>
|
||||||
|
addColumnAfter
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
@click="editor.chain().focus().deleteColumn().run()"
|
||||||
|
:disabled="!editor.can().deleteColumn()"
|
||||||
|
>
|
||||||
|
deleteColumn
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
@click="editor.chain().focus().addRowBefore().run()"
|
||||||
|
:disabled="!editor.can().addRowBefore()"
|
||||||
|
>
|
||||||
|
addRowBefore
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
@click="editor.chain().focus().addRowAfter().run()"
|
||||||
|
:disabled="!editor.can().addRowAfter()"
|
||||||
|
>
|
||||||
|
addRowAfter
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
@click="editor.chain().focus().deleteRow().run()"
|
||||||
|
:disabled="!editor.can().deleteRow()"
|
||||||
|
>
|
||||||
|
deleteRow
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
@click="editor.chain().focus().deleteTable().run()"
|
||||||
|
:disabled="!editor.can().deleteTable()"
|
||||||
|
>
|
||||||
|
deleteTable
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
@click="editor.chain().focus().mergeCells().run()"
|
||||||
|
:disabled="!editor.can().mergeCells()"
|
||||||
|
>
|
||||||
|
mergeCells
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
@click="editor.chain().focus().splitCell().run()"
|
||||||
|
:disabled="!editor.can().splitCell()"
|
||||||
|
>
|
||||||
|
splitCell
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
@click="editor.chain().focus().toggleHeaderColumn().run()"
|
||||||
|
:disabled="!editor.can().toggleHeaderColumn()"
|
||||||
|
>
|
||||||
|
toggleHeaderColumn
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
@click="editor.chain().focus().toggleHeaderRow().run()"
|
||||||
|
:disabled="!editor.can().toggleHeaderRow()"
|
||||||
|
>
|
||||||
|
toggleHeaderRow
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
@click="editor.chain().focus().toggleHeaderCell().run()"
|
||||||
|
:disabled="!editor.can().toggleHeaderCell()"
|
||||||
|
>
|
||||||
|
toggleHeaderCell
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
@click="editor.chain().focus().mergeOrSplit().run()"
|
||||||
|
:disabled="!editor.can().mergeOrSplit()"
|
||||||
|
>
|
||||||
|
mergeOrSplit
|
||||||
|
</BaseButton>
|
||||||
|
<!-- <BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
@click="
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.setCellAttribute('backgroundColor', '#FAF594')
|
||||||
|
.run()
|
||||||
|
"
|
||||||
|
:disabled="
|
||||||
|
!editor.can().setCellAttribute('backgroundColor', '#FAF594')
|
||||||
|
"
|
||||||
|
>
|
||||||
|
setCellAttribute
|
||||||
|
</BaseButton> -->
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
@click="editor.chain().focus().fixTables().run()"
|
||||||
|
:disabled="!editor.can().fixTables()"
|
||||||
|
>
|
||||||
|
fixTables
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
@click="editor.chain().focus().goToNextCell().run()"
|
||||||
|
:disabled="!editor.can().goToNextCell()"
|
||||||
|
>
|
||||||
|
goToNextCell
|
||||||
|
</BaseButton>
|
||||||
|
<BaseButton
|
||||||
|
class="editor-toolbar__button"
|
||||||
|
@click="editor.chain().focus().goToPreviousCell().run()"
|
||||||
|
:disabled="!editor.can().goToPreviousCell()"
|
||||||
|
>
|
||||||
|
goToPreviousCell
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-toolbar__segment">
|
||||||
|
<BaseButton class="editor-toolbar__button" @click="addImage" title="Add image from URL">
|
||||||
|
<span class="icon">
|
||||||
|
<icon :icon="['fa', 'fa-file-image']" />
|
||||||
|
</span>
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {ref, type PropType} from 'vue'
|
||||||
|
import {Editor} from '@tiptap/vue-3'
|
||||||
|
|
||||||
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
editor: {
|
||||||
|
default: null,
|
||||||
|
type: Editor as PropType<Editor>,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const tableMode = ref(false)
|
||||||
|
|
||||||
|
function toggleTableMode() {
|
||||||
|
tableMode.value = !tableMode.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function addImage() {
|
||||||
|
const url = window.prompt('URL')
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
props.editor?.chain().focus().setImage({ src: url }).run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLink() {
|
||||||
|
const previousUrl = props.editor.getAttributes('link').href
|
||||||
|
const url = window.prompt('URL', previousUrl)
|
||||||
|
|
||||||
|
// cancelled
|
||||||
|
if (url === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// empty
|
||||||
|
if (url === '') {
|
||||||
|
props.editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// update link
|
||||||
|
props.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);
|
||||||
|
// width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--grey-200);
|
||||||
|
border-color: var(--grey-300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
430
src/components/base/TipTap.vue
Normal file
430
src/components/base/TipTap.vue
Normal file
@ -0,0 +1,430 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tiptap">
|
||||||
|
<EditorToolbar v-if="editor" :editor="editor" />
|
||||||
|
<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} 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'
|
||||||
|
|
||||||
|
// const CustomDocument = Document.extend({
|
||||||
|
// content: 'taskList',
|
||||||
|
// })
|
||||||
|
|
||||||
|
const CustomTaskItem = TaskItem.extend({
|
||||||
|
content: 'inline*',
|
||||||
|
})
|
||||||
|
|
||||||
|
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 props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: String as PropType<string>,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'change'])
|
||||||
|
|
||||||
|
const inputHTML = ref('')
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
() => {
|
||||||
|
if (!props.modelValue.startsWith(TIPTAP_TEXT_VALUE_PREFIX)) {
|
||||||
|
// convert Markdown to HTML
|
||||||
|
return TIPTAP_TEXT_VALUE_PREFIX + marked.parse(props.modelValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.modelValue.replace(tiptapRegex, '')
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const debouncedInputHTML = refDebounced(inputHTML, 1000)
|
||||||
|
|
||||||
|
watch(debouncedInputHTML, (value) => {
|
||||||
|
emit('update:modelValue', TIPTAP_TEXT_VALUE_PREFIX + value)
|
||||||
|
emit('change', TIPTAP_TEXT_VALUE_PREFIX + 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,
|
||||||
|
// // Default TableCell
|
||||||
|
// // TableCell,
|
||||||
|
// // 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 {
|
||||||
|
> * + * {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table-specific styling */
|
||||||
|
.ProseMirror {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-cursor {
|
||||||
|
cursor: ew-resize;
|
||||||
|
cursor: col-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
// tasklist
|
||||||
|
ul[data-type="taskList"] {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
> 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,3 +1,6 @@
|
|||||||
import {createAsyncComponent} from '@/helpers/createAsyncComponent'
|
import {createAsyncComponent} from '@/helpers/createAsyncComponent'
|
||||||
|
|
||||||
export default createAsyncComponent(() => import('@/components/input/editor.vue'))
|
// const Editor = createAsyncComponent(() => import('@/components/input/editor.vue'))
|
||||||
|
const TipTap = createAsyncComponent(() => import('@/components/base/TipTap.vue'))
|
||||||
|
|
||||||
|
export default TipTap
|
||||||
|
@ -6,6 +6,10 @@ import {
|
|||||||
faArchive,
|
faArchive,
|
||||||
faArrowLeft,
|
faArrowLeft,
|
||||||
faArrowUpFromBracket,
|
faArrowUpFromBracket,
|
||||||
|
faBold,
|
||||||
|
faItalic,
|
||||||
|
faStrikethrough,
|
||||||
|
faCode,
|
||||||
faBars,
|
faBars,
|
||||||
faBell,
|
faBell,
|
||||||
faBolt,
|
faBolt,
|
||||||
@ -29,6 +33,7 @@ import {
|
|||||||
faFilter,
|
faFilter,
|
||||||
faForward,
|
faForward,
|
||||||
faGripLines,
|
faGripLines,
|
||||||
|
faHeader,
|
||||||
faHistory,
|
faHistory,
|
||||||
faImage,
|
faImage,
|
||||||
faKeyboard,
|
faKeyboard,
|
||||||
@ -59,14 +64,26 @@ import {
|
|||||||
faTimes,
|
faTimes,
|
||||||
faTrashAlt,
|
faTrashAlt,
|
||||||
faUser,
|
faUser,
|
||||||
faUsers, faX,
|
faUsers,
|
||||||
|
faQuoteRight,
|
||||||
|
faListUl,
|
||||||
|
faLink,
|
||||||
|
faUndo,
|
||||||
|
faRedo,
|
||||||
|
faUnlink,
|
||||||
|
faParagraph,
|
||||||
|
faTable,
|
||||||
|
faX,
|
||||||
} from '@fortawesome/free-solid-svg-icons'
|
} from '@fortawesome/free-solid-svg-icons'
|
||||||
import {
|
import {
|
||||||
faBellSlash,
|
faBellSlash,
|
||||||
faCalendarAlt,
|
faCalendarAlt,
|
||||||
|
faCheckSquare,
|
||||||
faClock,
|
faClock,
|
||||||
faComments,
|
faComments,
|
||||||
|
faFileImage,
|
||||||
faSave,
|
faSave,
|
||||||
|
faSquareCheck,
|
||||||
faStar,
|
faStar,
|
||||||
faSun,
|
faSun,
|
||||||
faTimesCircle,
|
faTimesCircle,
|
||||||
@ -76,6 +93,21 @@ import {FontAwesomeIcon} from '@fortawesome/vue-fontawesome'
|
|||||||
|
|
||||||
import type {FontAwesomeIcon as FontAwesomeIconFixedTypes} from '@/types/vue-fontawesome'
|
import type {FontAwesomeIcon as FontAwesomeIconFixedTypes} from '@/types/vue-fontawesome'
|
||||||
|
|
||||||
|
library.add(faBold)
|
||||||
|
library.add(faUndo)
|
||||||
|
library.add(faRedo)
|
||||||
|
library.add(faItalic)
|
||||||
|
library.add(faLink)
|
||||||
|
library.add(faUnlink)
|
||||||
|
library.add(faParagraph)
|
||||||
|
library.add(faSquareCheck)
|
||||||
|
library.add(faTable)
|
||||||
|
library.add(faFileImage)
|
||||||
|
library.add(faCheckSquare)
|
||||||
|
library.add(faStrikethrough)
|
||||||
|
library.add(faCode)
|
||||||
|
library.add(faQuoteRight)
|
||||||
|
library.add(faListUl)
|
||||||
library.add(faAlignLeft)
|
library.add(faAlignLeft)
|
||||||
library.add(faAngleRight)
|
library.add(faAngleRight)
|
||||||
library.add(faArchive)
|
library.add(faArchive)
|
||||||
@ -107,6 +139,7 @@ library.add(faFillDrip)
|
|||||||
library.add(faFilter)
|
library.add(faFilter)
|
||||||
library.add(faForward)
|
library.add(faForward)
|
||||||
library.add(faGripLines)
|
library.add(faGripLines)
|
||||||
|
library.add(faHeader)
|
||||||
library.add(faHistory)
|
library.add(faHistory)
|
||||||
library.add(faImage)
|
library.add(faImage)
|
||||||
library.add(faKeyboard)
|
library.add(faKeyboard)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user