forked from vikunja/frontend
feat: move from easymde to tiptap editor (#2222)
Reviewed-on: vikunja/frontend#2222
This commit is contained in:
commit
26fc9b4e4f
@ -1,40 +0,0 @@
|
||||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {UserProjectFactory} from '../../factories/users_project'
|
||||
import {BucketFactory} from '../../factories/bucket'
|
||||
|
||||
describe('Editor', () => {
|
||||
createFakeUserAndLogin()
|
||||
|
||||
beforeEach(() => {
|
||||
ProjectFactory.create(1)
|
||||
BucketFactory.create(1)
|
||||
TaskFactory.truncate()
|
||||
UserProjectFactory.truncate()
|
||||
})
|
||||
|
||||
it('Has a preview with checkable checkboxes', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
description: `# Test Heading
|
||||
* Bullet 1
|
||||
* Bullet 2
|
||||
|
||||
* [ ] Checklist
|
||||
* [x] Checklist checked
|
||||
`,
|
||||
bucket_id: 1,
|
||||
})
|
||||
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
cy.get('input[type=checkbox][data-checkbox-num=0]')
|
||||
.click()
|
||||
|
||||
cy.get('.task-view .details.content.description h3 span.is-small.has-text-success')
|
||||
.contains('Saved!')
|
||||
.should('exist')
|
||||
cy.get('.preview.content')
|
||||
.should('contain', 'Test Heading')
|
||||
})
|
||||
})
|
@ -24,7 +24,7 @@ function addLabelToTaskAndVerify(labelTitle: string) {
|
||||
.first()
|
||||
.click()
|
||||
|
||||
cy.get('.global-notification', { timeout: 4000 })
|
||||
cy.get('.global-notification', {timeout: 4000})
|
||||
.should('contain', 'Success')
|
||||
cy.get('.task-view .details.labels-list .multiselect .input-wrapper span.tag')
|
||||
.should('exist')
|
||||
@ -122,7 +122,7 @@ describe('Task', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
index: 1,
|
||||
description: 'Lorem ipsum dolor sit amet.'
|
||||
description: 'Lorem ipsum dolor sit amet.',
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
@ -143,7 +143,7 @@ describe('Task', () => {
|
||||
id: 1,
|
||||
index: 1,
|
||||
done: true,
|
||||
done_at: new Date().toISOString()
|
||||
done_at: new Date().toISOString(),
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
@ -196,13 +196,13 @@ describe('Task', () => {
|
||||
it('Can edit the description', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
description: 'Lorem ipsum dolor sit amet.'
|
||||
description: 'Lorem ipsum dolor sit amet.',
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.task-view .details.content.description .editor button')
|
||||
cy.get('.task-view .details.content.description .tiptap button.done-edit')
|
||||
.click()
|
||||
cy.get('.task-view .details.content.description .editor .vue-easymde .EasyMDEContainer .CodeMirror-scroll')
|
||||
cy.get('.task-view .details.content.description .tiptap__editor .tiptap.ProseMirror')
|
||||
.type('{selectall}New Description')
|
||||
cy.get('[data-cy="saveEditor"]')
|
||||
.contains('Save')
|
||||
@ -219,7 +219,7 @@ describe('Task', () => {
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.task-view .comments .media.comment .editor .vue-easymde .EasyMDEContainer .CodeMirror-scroll')
|
||||
cy.get('.task-view .comments .media.comment .tiptap__editor .tiptap.ProseMirror')
|
||||
.should('be.visible')
|
||||
.type('{selectall}New Comment')
|
||||
cy.get('.task-view .comments .media.comment .button:not([disabled])')
|
||||
@ -227,7 +227,7 @@ describe('Task', () => {
|
||||
.should('be.visible')
|
||||
.click()
|
||||
|
||||
cy.get('.task-view .comments .media.comment .editor')
|
||||
cy.get('.task-view .comments .media.comment .tiptap__editor')
|
||||
.should('contain', 'New Comment')
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
@ -236,7 +236,7 @@ describe('Task', () => {
|
||||
it('Can move a task to another project', () => {
|
||||
const projects = ProjectFactory.create(2)
|
||||
BucketFactory.create(2, {
|
||||
project_id: '{increment}'
|
||||
project_id: '{increment}',
|
||||
})
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
@ -380,7 +380,7 @@ describe('Task', () => {
|
||||
|
||||
addLabelToTaskAndVerify(labels[0].title)
|
||||
})
|
||||
|
||||
|
||||
it('Can add a label to a task and it shows up on the kanban board afterwards', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
@ -389,18 +389,18 @@ describe('Task', () => {
|
||||
})
|
||||
const labels = LabelFactory.create(1)
|
||||
LabelTaskFactory.truncate()
|
||||
|
||||
|
||||
cy.visit(`/projects/${projects[0].id}/kanban`)
|
||||
|
||||
|
||||
cy.get('.bucket .task')
|
||||
.contains(tasks[0].title)
|
||||
.click()
|
||||
|
||||
|
||||
addLabelToTaskAndVerify(labels[0].title)
|
||||
|
||||
|
||||
cy.get('.modal-content .close')
|
||||
.click()
|
||||
|
||||
|
||||
cy.get('.bucket .task')
|
||||
.should('contain.text', labels[0].title)
|
||||
})
|
||||
@ -461,7 +461,7 @@ describe('Task', () => {
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
})
|
||||
|
||||
|
||||
it('Can set a reminder', () => {
|
||||
TaskReminderFactory.truncate()
|
||||
const tasks = TaskFactory.create(1, {
|
||||
@ -543,7 +543,7 @@ describe('Task', () => {
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
})
|
||||
|
||||
|
||||
it('Allows to set a custom relative reminder when the task already has a due date', () => {
|
||||
TaskReminderFactory.truncate()
|
||||
const tasks = TaskFactory.create(1, {
|
||||
@ -579,7 +579,7 @@ describe('Task', () => {
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
})
|
||||
|
||||
|
||||
it('Allows to set a fixed reminder when the task already has a due date', () => {
|
||||
TaskReminderFactory.truncate()
|
||||
const tasks = TaskFactory.create(1, {
|
||||
@ -609,7 +609,7 @@ describe('Task', () => {
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
})
|
||||
|
||||
|
||||
it('Can set a priority for a task', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
@ -647,7 +647,7 @@ describe('Task', () => {
|
||||
.select('50%')
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
|
||||
|
||||
cy.wait(200)
|
||||
|
||||
cy.get('.task-view .columns.details .column')
|
||||
@ -656,7 +656,7 @@ describe('Task', () => {
|
||||
.should('be.visible')
|
||||
.should('have.value', '0.5')
|
||||
})
|
||||
|
||||
|
||||
it('Can add an attachment to a task', () => {
|
||||
TaskAttachmentFactory.truncate()
|
||||
const tasks = TaskFactory.create(1, {
|
||||
@ -691,35 +691,119 @@ describe('Task', () => {
|
||||
cy.get('.bucket .task .footer .icon svg.fa-paperclip')
|
||||
.should('exist')
|
||||
})
|
||||
|
||||
|
||||
it('Can check items off a checklist', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
description: `
|
||||
This is a checklist:
|
||||
|
||||
* [ ] one item
|
||||
* [ ] another item
|
||||
* [ ] third item
|
||||
* [ ] fourth item
|
||||
* [x] and this one is already done
|
||||
`,
|
||||
<ul data-type="taskList">
|
||||
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||
<div><p>First Item</p></div>
|
||||
</li>
|
||||
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||
<div><p>Second Item</p></div>
|
||||
</li>
|
||||
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||
<div><p>Third Item</p></div>
|
||||
</li>
|
||||
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||
<div><p>Fourth Item</p></div>
|
||||
</li>
|
||||
<li data-checked="true" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||
<div><p>Fifth Item</p></div>
|
||||
</li>
|
||||
</ul>`,
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
|
||||
cy.get('.task-view .checklist-summary')
|
||||
.should('contain.text', '1 of 5 tasks')
|
||||
cy.get('.editor .content ul > li input[type=checkbox]')
|
||||
cy.get('.tiptap__editor ul > li input[type=checkbox]')
|
||||
.eq(2)
|
||||
.click()
|
||||
|
||||
cy.get('.editor .content ul > li input[type=checkbox]')
|
||||
|
||||
cy.get('.task-view .details.content.description h3 span.is-small.has-text-success')
|
||||
.contains('Saved!')
|
||||
.should('exist')
|
||||
cy.get('.tiptap__editor ul > li input[type=checkbox]')
|
||||
.eq(2)
|
||||
.should('be.checked')
|
||||
cy.get('.editor .content input[type=checkbox]')
|
||||
cy.get('.tiptap__editor input[type=checkbox]')
|
||||
.should('have.length', 5)
|
||||
cy.get('.task-view .checklist-summary')
|
||||
.should('contain.text', '2 of 5 tasks')
|
||||
})
|
||||
|
||||
it('Should use the editor to render description', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
description: `
|
||||
<h1>Lorem Ipsum</h1>
|
||||
<p>Dolor sit amet</p>
|
||||
<ul data-type="taskList">
|
||||
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||
<div><p>First Item</p></div>
|
||||
</li>
|
||||
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||
<div><p>Second Item</p></div>
|
||||
</li>
|
||||
</ul>`,
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.tiptap__editor ul > li input[type=checkbox]')
|
||||
.should('exist')
|
||||
cy.get('.tiptap__editor h1')
|
||||
.contains('Lorem Ipsum')
|
||||
.should('exist')
|
||||
cy.get('.tiptap__editor p')
|
||||
.contains('Dolor sit amet')
|
||||
.should('exist')
|
||||
})
|
||||
|
||||
it.only('Should render an image from attachment', async () => {
|
||||
|
||||
TaskAttachmentFactory.truncate()
|
||||
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
description: '',
|
||||
})
|
||||
|
||||
cy.readFile('cypress/fixtures/image.jpg', null).then(file => {
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('files', new Blob([file]), 'image.jpg')
|
||||
|
||||
cy.request({
|
||||
method: 'PUT',
|
||||
url: `${Cypress.env('API_URL')}/tasks/${tasks[0].id}/attachments`,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${window.localStorage.getItem('token')}`,
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
.then(({body}) => {
|
||||
const dec = new TextDecoder('utf-8')
|
||||
const {success} = JSON.parse(dec.decode(body))
|
||||
|
||||
TaskFactory.create(1, {
|
||||
id: 1,
|
||||
description: `<img src="${Cypress.env('API_URL')}/tasks/${tasks[0].id}/attachments/${success[0].id}" alt="test image">`,
|
||||
})
|
||||
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.tiptap__editor img')
|
||||
.should('be.visible')
|
||||
.and(($img) => {
|
||||
// "naturalWidth" and "naturalHeight" are set when the image loads
|
||||
expect($img[0].naturalWidth).to.be.greaterThan(0)
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
42
package.json
42
package.json
@ -55,30 +55,64 @@
|
||||
"@kyvg/vue3-notification": "3.0.2",
|
||||
"@sentry/tracing": "7.74.0",
|
||||
"@sentry/vue": "7.74.0",
|
||||
"@tiptap/core": "2.1.12",
|
||||
"@tiptap/extension-blockquote": "2.1.12",
|
||||
"@tiptap/extension-bold": "2.1.12",
|
||||
"@tiptap/extension-bullet-list": "2.1.12",
|
||||
"@tiptap/extension-code": "2.1.12",
|
||||
"@tiptap/extension-code-block-lowlight": "2.0.3",
|
||||
"@tiptap/extension-document": "2.0.3",
|
||||
"@tiptap/extension-dropcursor": "2.1.12",
|
||||
"@tiptap/extension-gapcursor": "2.1.12",
|
||||
"@tiptap/extension-hard-break": "2.1.12",
|
||||
"@tiptap/extension-heading": "2.1.12",
|
||||
"@tiptap/extension-history": "2.1.12",
|
||||
"@tiptap/extension-horizontal-rule": "2.1.12",
|
||||
"@tiptap/extension-image": "2.0.3",
|
||||
"@tiptap/extension-italic": "2.1.12",
|
||||
"@tiptap/extension-link": "2.0.3",
|
||||
"@tiptap/extension-list-item": "2.1.12",
|
||||
"@tiptap/extension-ordered-list": "2.1.12",
|
||||
"@tiptap/extension-paragraph": "2.1.12",
|
||||
"@tiptap/extension-placeholder": "2.1.12",
|
||||
"@tiptap/extension-strike": "2.1.12",
|
||||
"@tiptap/extension-table": "2.0.3",
|
||||
"@tiptap/extension-table-cell": "2.0.3",
|
||||
"@tiptap/extension-table-header": "2.0.3",
|
||||
"@tiptap/extension-table-row": "2.0.3",
|
||||
"@tiptap/extension-task-item": "2.0.3",
|
||||
"@tiptap/extension-task-list": "2.0.3",
|
||||
"@tiptap/extension-text": "2.1.12",
|
||||
"@tiptap/extension-typography": "2.0.3",
|
||||
"@tiptap/extension-underline": "2.1.12",
|
||||
"@tiptap/pm": "2.1.12",
|
||||
"@tiptap/suggestion": "2.1.12",
|
||||
"@tiptap/vue-3": "2.0.3",
|
||||
"@types/is-touch-device": "1.0.0",
|
||||
"@types/lodash.clonedeep": "4.5.7",
|
||||
"@types/sortablejs": "1.15.0",
|
||||
"@vueuse/core": "10.5.0",
|
||||
"@vueuse/router": "10.5.0",
|
||||
"axios": "1.5.1",
|
||||
"blurhash": "2.0.5",
|
||||
"bulma-css-variables": "0.9.33",
|
||||
"camel-case": "4.1.2",
|
||||
"codemirror": "5.65.15",
|
||||
"date-fns": "2.30.0",
|
||||
"dayjs": "1.11.10",
|
||||
"dompurify": "3.0.6",
|
||||
"easymde": "2.18.0",
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"flatpickr": "4.6.13",
|
||||
"flexsearch": "0.7.31",
|
||||
"floating-vue": "2.0.0-beta.24",
|
||||
"highlight.js": "11.9.0",
|
||||
"is-touch-device": "1.0.1",
|
||||
"klona": "2.0.6",
|
||||
"lodash.debounce": "4.0.8",
|
||||
"marked": "9.1.2",
|
||||
"lowlight": "2.7.0",
|
||||
"pinia": "2.1.7",
|
||||
"register-service-worker": "1.7.2",
|
||||
"snake-case": "3.0.4",
|
||||
"sortablejs": "1.15.0",
|
||||
"tippy.js": "6.3.7",
|
||||
"ufo": "1.3.1",
|
||||
"vue": "3.3.6",
|
||||
"vue-advanced-cropper": "2.8.8",
|
||||
|
853
pnpm-lock.yaml
generated
853
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -27,6 +27,13 @@
|
||||
"histoire"
|
||||
]
|
||||
},
|
||||
{
|
||||
"groupName": "tiptap",
|
||||
"matchPackagePrefixes": [
|
||||
"@tiptap/",
|
||||
"tiptap"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matchDepTypes": ["devDependencies"],
|
||||
"groupName": "dev-dependencies",
|
||||
|
@ -1,3 +1,5 @@
|
||||
import {createAsyncComponent} from '@/helpers/createAsyncComponent'
|
||||
|
||||
export default createAsyncComponent(() => import('@/components/input/editor.vue'))
|
||||
const TipTap = createAsyncComponent(() => import('@/components/input/editor/TipTap.vue'))
|
||||
|
||||
export default TipTap
|
||||
|
@ -1,444 +0,0 @@
|
||||
<template>
|
||||
<div class="editor">
|
||||
<div class="clear"></div>
|
||||
|
||||
<vue-easymde
|
||||
:configs="config"
|
||||
@change="() => bubbleNow()"
|
||||
@update:modelValue="handleInput"
|
||||
class="content"
|
||||
v-if="isEditActive"
|
||||
v-model="text"/>
|
||||
|
||||
<div class="preview content" v-html="preview" v-if="isPreviewActive && text !== ''">
|
||||
</div>
|
||||
|
||||
<p class="has-text-centered has-text-grey is-italic my-5" v-if="showPreviewText">
|
||||
{{ emptyText }}
|
||||
<template v-if="isEditEnabled">
|
||||
<ButtonLink
|
||||
@click="toggleEdit"
|
||||
v-shortcut="editShortcut"
|
||||
class="d-print-none">
|
||||
{{ $t('input.editor.edit') }}
|
||||
</ButtonLink>.
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<ul class="actions d-print-none" v-if="bottomActions.length > 0">
|
||||
<li v-if="isEditEnabled && !showPreviewText && showSave">
|
||||
<BaseButton
|
||||
v-if="showEditButton"
|
||||
@click="toggleEdit"
|
||||
v-shortcut="editShortcut">
|
||||
{{ $t('input.editor.edit') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-else-if="isEditActive"
|
||||
@click="bubbleSaveClick"
|
||||
class="done-edit">
|
||||
{{ $t('misc.save') }}
|
||||
</BaseButton>
|
||||
</li>
|
||||
<li v-for="(action, k) in bottomActions" :key="k">
|
||||
<BaseButton @click="action.action">{{ action.title }}</BaseButton>
|
||||
</li>
|
||||
</ul>
|
||||
<template v-else-if="isEditEnabled && showSave">
|
||||
<ul v-if="showEditButton" class="actions d-print-none">
|
||||
<li>
|
||||
<BaseButton
|
||||
@click="toggleEdit"
|
||||
v-shortcut="editShortcut">
|
||||
{{ $t('input.editor.edit') }}
|
||||
</BaseButton>
|
||||
</li>
|
||||
</ul>
|
||||
<x-button
|
||||
v-else-if="isEditActive"
|
||||
@click="bubbleSaveClick"
|
||||
variant="secondary"
|
||||
:shadow="false"
|
||||
v-cy="'saveEditor'">
|
||||
{{ $t('misc.save') }}
|
||||
</x-button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, nextTick, onMounted, ref, toRefs, watch} from 'vue'
|
||||
|
||||
import VueEasymde from './vue-easymde.vue'
|
||||
import {marked} from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
import {createEasyMDEConfig} from './editorConfig'
|
||||
|
||||
import AttachmentModel from '@/models/attachment'
|
||||
import AttachmentService from '@/services/attachment'
|
||||
|
||||
import {setupMarkdownRenderer} from '@/helpers/markdownRenderer'
|
||||
import {findCheckboxesInText} from '@/helpers/checklistFromText'
|
||||
import {createRandomID} from '@/helpers/randomId'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import ButtonLink from '@/components/misc/ButtonLink.vue'
|
||||
import type {IAttachment} from '@/modelTypes/IAttachment'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
uploadEnabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
uploadCallback: {
|
||||
type: Function,
|
||||
},
|
||||
hasPreview: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
previewIsDefault: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
isEditEnabled: {
|
||||
default: true,
|
||||
},
|
||||
bottomActions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
emptyText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showSave: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// If a key is passed the editor will go in "edit" mode when the key is pressed.
|
||||
// Disabled if an empty string is passed.
|
||||
editShortcut: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'save'])
|
||||
|
||||
const text = ref('')
|
||||
const isEditActive = ref(false)
|
||||
const isPreviewActive = ref(true)
|
||||
|
||||
const showPreviewText = computed(() => isPreviewActive.value && text.value === '' && props.emptyText !== '')
|
||||
const showEditButton = computed(() => !isEditActive.value && text.value !== '')
|
||||
|
||||
const preview = ref('')
|
||||
const attachmentService = new AttachmentService()
|
||||
|
||||
type CacheKey = `${ITask['id']}-${IAttachment['id']}`
|
||||
const loadedAttachments = ref<{ [key: CacheKey]: string }>({})
|
||||
const config = ref(createEasyMDEConfig({
|
||||
placeholder: props.placeholder,
|
||||
uploadImage: props.uploadEnabled,
|
||||
imageUploadFunction: props.uploadCallback,
|
||||
}))
|
||||
|
||||
const checkboxId = ref(createRandomID())
|
||||
|
||||
const {modelValue} = toRefs(props)
|
||||
|
||||
watch(
|
||||
modelValue,
|
||||
async (value) => {
|
||||
text.value = value
|
||||
await nextTick()
|
||||
renderPreview()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
text,
|
||||
(newVal, oldVal) => {
|
||||
// Only bubble the new value if it actually changed, but not if the component just got mounted and the text changed from the outside.
|
||||
if (oldVal === '' && text.value === modelValue.value) {
|
||||
return
|
||||
}
|
||||
bubbleNow()
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
if (modelValue.value !== '') {
|
||||
text.value = modelValue.value
|
||||
}
|
||||
|
||||
if (props.previewIsDefault && props.hasPreview) {
|
||||
nextTick(() => renderPreview())
|
||||
return
|
||||
}
|
||||
|
||||
isPreviewActive.value = false
|
||||
isEditActive.value = true
|
||||
})
|
||||
|
||||
|
||||
// This gets triggered when only pasting content into the editor.
|
||||
// A change event would not get generated by that, an input event does.
|
||||
// Therefore, we're using this handler to catch paste events.
|
||||
// But because this also gets triggered when typing into the editor, we give
|
||||
// it a higher timeout to make the timouts cancel each other in that case so
|
||||
// that in the end, only one change event is triggered to the outside per change.
|
||||
function handleInput(val: string) {
|
||||
// Don't bubble if the text is up to date
|
||||
if (val === text.value) {
|
||||
return
|
||||
}
|
||||
|
||||
text.value = val
|
||||
bubbleNow()
|
||||
}
|
||||
|
||||
function bubbleNow() {
|
||||
emit('update:modelValue', text.value)
|
||||
}
|
||||
|
||||
function replaceAt(str: string, index: number, replacement: string) {
|
||||
return str.slice(0, index) + replacement + str.slice(index + replacement.length)
|
||||
}
|
||||
|
||||
function findNthIndex(str: string, n: number) {
|
||||
const checkboxes = findCheckboxesInText(str)
|
||||
return checkboxes[n]
|
||||
}
|
||||
|
||||
function renderPreview() {
|
||||
setupMarkdownRenderer(checkboxId.value)
|
||||
|
||||
preview.value = DOMPurify.sanitize(marked(text.value, {
|
||||
mangle: false,
|
||||
headerIds: false,
|
||||
}), {ADD_ATTR: ['target']})
|
||||
|
||||
// Since the render function is synchronous, we can't do async http requests in it.
|
||||
// Therefore, we can't resolve the blob url at (markdown) compile time.
|
||||
// To work around this, we modify the url after rendering it in the vue component.
|
||||
// We're doing the whole thing in the next tick to ensure the image elements are available in the
|
||||
// dom tree. If we're calling this right after setting this.preview it could be the images were
|
||||
// not already made available.
|
||||
// Some docs at https://stackoverflow.com/q/62865160/10924593
|
||||
nextTick().then(async () => {
|
||||
const attachmentImage = document.querySelectorAll<HTMLImageElement>('.attachment-image')
|
||||
if (attachmentImage) {
|
||||
Array.from(attachmentImage).forEach(async (img) => {
|
||||
// The url is something like /tasks/<id>/attachments/<id>
|
||||
const parts = img.dataset.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 textCheckbox = document.querySelectorAll<HTMLInputElement>(`.text-checkbox-${checkboxId.value}`)
|
||||
if (textCheckbox) {
|
||||
Array.from(textCheckbox).forEach(check => {
|
||||
check.removeEventListener('change', handleCheckboxClick)
|
||||
check.addEventListener('change', handleCheckboxClick)
|
||||
check.parentElement?.classList.add('has-checkbox')
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleCheckboxClick(e: Event) {
|
||||
// Find the original markdown checkbox this is targeting
|
||||
const checked = (e.target as HTMLInputElement).checked
|
||||
const numMarkdownCheck = Number((e.target as HTMLInputElement).dataset.checkboxNum)
|
||||
|
||||
const index = findNthIndex(text.value, numMarkdownCheck)
|
||||
if (index < 0 || typeof index === 'undefined') {
|
||||
console.debug('no index found')
|
||||
return
|
||||
}
|
||||
const projectPrefix = text.value.substring(index, index + 1)
|
||||
|
||||
console.debug({index, projectPrefix, checked, text: text.value})
|
||||
|
||||
text.value = replaceAt(text.value, index, `${projectPrefix} ${checked ? '[x]' : '[ ]'} `)
|
||||
bubbleNow()
|
||||
emit('save', text.value)
|
||||
renderPreview()
|
||||
}
|
||||
|
||||
function toggleEdit() {
|
||||
isPreviewActive.value = false
|
||||
isEditActive.value = true
|
||||
}
|
||||
|
||||
function bubbleSaveClick() {
|
||||
isPreviewActive.value = true
|
||||
isEditActive.value = false
|
||||
renderPreview()
|
||||
bubbleNow()
|
||||
emit('save', text.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import 'codemirror/lib/codemirror.css';
|
||||
@import 'highlight.js/scss/base16/equilibrium-gray-light';
|
||||
|
||||
.editor {
|
||||
.clear {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.preview.content {
|
||||
margin-bottom: .5rem;
|
||||
overflow-wrap: anywhere; // Safari does not understand "break-word" so we put that first to make sure it at least is able to show it somewhat properly there.
|
||||
overflow-wrap: break-word;
|
||||
|
||||
ul li {
|
||||
input[type="checkbox"] {
|
||||
margin-right: .5rem;
|
||||
}
|
||||
|
||||
&.has-checkbox {
|
||||
margin-left: -1.25rem;
|
||||
list-style: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.CodeMirror {
|
||||
padding: .5rem;
|
||||
border: 1px solid var(--grey-200) !important;
|
||||
background: var(--white);
|
||||
|
||||
&-lines pre {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
&-placeholder {
|
||||
color: var(--grey-400) !important;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
&-cursor {
|
||||
border-color: var(--grey-700);
|
||||
}
|
||||
}
|
||||
|
||||
.editor-preview {
|
||||
padding: 0;
|
||||
|
||||
&-side {
|
||||
padding: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
background: var(--grey-50);
|
||||
border: 1px solid var(--grey-200);
|
||||
border-bottom: none;
|
||||
|
||||
button {
|
||||
color: var(--grey-700);
|
||||
|
||||
&.active {
|
||||
background: var(--grey-200);
|
||||
}
|
||||
|
||||
svg {
|
||||
vertical-align: middle;
|
||||
|
||||
&, rect {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
margin-left: -3px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--grey-200);
|
||||
border-color: var(--grey-300);
|
||||
}
|
||||
}
|
||||
|
||||
i.separator {
|
||||
border-color: var(--grey-200) !important;
|
||||
}
|
||||
}
|
||||
|
||||
pre.CodeMirror-line {
|
||||
margin-bottom: 0 !important;
|
||||
color: var(--grey-700) !important;
|
||||
}
|
||||
|
||||
.cm-header {
|
||||
font-family: $vikunja-font;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
ul.actions {
|
||||
font-size: .8rem;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
|
||||
&::after {
|
||||
content: '·';
|
||||
padding: 0 .25rem;
|
||||
}
|
||||
|
||||
&:last-child:after {
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
||||
&, a {
|
||||
color: var(--grey-500);
|
||||
|
||||
&.done-edit {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.vue-easymde.content {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
</style>
|
144
src/components/input/editor/CommandsList.vue
Normal file
144
src/components/input/editor/CommandsList.vue
Normal file
@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div class="items">
|
||||
<template v-if="items.length">
|
||||
<button
|
||||
class="item"
|
||||
:class="{ 'is-selected': index === selectedIndex }"
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
@click="selectItem(index)"
|
||||
>
|
||||
<icon :icon="item.icon"/>
|
||||
<div class="description">
|
||||
<p>{{ item.title }}</p>
|
||||
<p>{{ item.description }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<div class="item" v-else>
|
||||
No result
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
|
||||
command: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
selectedIndex: 0,
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
items() {
|
||||
this.selectedIndex = 0
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onKeyDown({event}) {
|
||||
if (event.key === 'ArrowUp') {
|
||||
this.upHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
this.downHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
this.enterHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
|
||||
upHandler() {
|
||||
this.selectedIndex = ((this.selectedIndex + this.items.length) - 1) % this.items.length
|
||||
},
|
||||
|
||||
downHandler() {
|
||||
this.selectedIndex = (this.selectedIndex + 1) % this.items.length
|
||||
},
|
||||
|
||||
enterHandler() {
|
||||
this.selectItem(this.selectedIndex)
|
||||
},
|
||||
|
||||
selectItem(index) {
|
||||
const item = this.items[index]
|
||||
|
||||
if (item) {
|
||||
this.command(item)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.items {
|
||||
padding: 0.2rem;
|
||||
position: relative;
|
||||
border-radius: 0.5rem;
|
||||
background: #fff;
|
||||
color: rgba(0, 0, 0, 0.8);
|
||||
overflow: hidden;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0px 10px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border-radius: $radius;
|
||||
border: 0;
|
||||
padding: 0.2rem 0.4rem;
|
||||
transition: background-color $transition;
|
||||
|
||||
&.is-selected, &:hover {
|
||||
background: var(--grey-100);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
> svg {
|
||||
box-sizing: border-box;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 1px solid var(--grey-300);
|
||||
padding: .5rem;
|
||||
margin-right: .5rem;
|
||||
border-radius: $radius;
|
||||
color: var(--grey-700);
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: .9rem;
|
||||
|
||||
p:last-child {
|
||||
font-size: .75rem;
|
||||
color: var(--grey-500);
|
||||
}
|
||||
}
|
||||
</style>
|
439
src/components/input/editor/EditorToolbar.vue
Normal file
439
src/components/input/editor/EditorToolbar.vue
Normal file
@ -0,0 +1,439 @@
|
||||
<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 }) }"
|
||||
v-tooltip="$t('input.editor.heading1')"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-header']"/>
|
||||
<span class="icon__lower-text">1</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
|
||||
:class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
|
||||
v-tooltip="$t('input.editor.heading2')"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-header']"/>
|
||||
<span class="icon__lower-text">2</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
|
||||
:class="{ 'is-active': editor.isActive('heading', { level: 3 }) }"
|
||||
v-tooltip="$t('input.editor.heading3')"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-header']"/>
|
||||
<span class="icon__lower-text">3</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="editor-toolbar__segment">
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleBold().run()"
|
||||
:class="{ 'is-active': editor.isActive('bold') }"
|
||||
v-tooltip="$t('input.editor.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') }"
|
||||
v-tooltip="$t('input.editor.italic')"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-italic']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleUnderline().run()"
|
||||
:class="{ 'is-active': editor.isActive('underline') }"
|
||||
v-tooltip="$t('input.editor.underline')"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-underline']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleStrike().run()"
|
||||
:class="{ 'is-active': editor.isActive('strike') }"
|
||||
v-tooltip="$t('input.editor.strikethrough')"
|
||||
>
|
||||
<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().toggleCodeBlock().run()"
|
||||
:class="{ 'is-active': editor.isActive('codeBlock') }"
|
||||
v-tooltip="$t('input.editor.code')"
|
||||
>
|
||||
<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') }"
|
||||
v-tooltip="$t('input.editor.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') }"
|
||||
v-tooltip="$t('input.editor.bulletList')"
|
||||
>
|
||||
<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') }"
|
||||
v-tooltip="$t('input.editor.orderedList')"
|
||||
>
|
||||
<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') }"
|
||||
v-tooltip="$t('input.editor.taskList')"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon icon="fa-list-check"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="editor-toolbar__segment">
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="openImagePicker"
|
||||
v-tooltip="$t('input.editor.image')"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon icon="fa-image"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="editor-toolbar__segment">
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="setLink"
|
||||
:class="{ 'is-active': editor.isActive('link') }"
|
||||
title="set link"
|
||||
v-tooltip="$t('input.editor.link')"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-link']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().setParagraph().run()"
|
||||
:class="{ 'is-active': editor.isActive('paragraph') }"
|
||||
title="paragraph"
|
||||
v-tooltip="$t('input.editor.text')"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-paragraph']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().setHorizontalRule().run()"
|
||||
v-tooltip="$t('input.editor.horizontalRule')"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-ruler-horizontal']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="editor-toolbar__segment">
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().undo().run()"
|
||||
v-tooltip="$t('input.editor.undo')"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-undo']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().redo().run()"
|
||||
v-tooltip="$t('input.editor.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') }"
|
||||
v-tooltip="$t('input.editor.table.title')"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
import {Editor} from '@tiptap/vue-3'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
const {
|
||||
editor = null,
|
||||
} = defineProps<{
|
||||
editor: Editor,
|
||||
}>()
|
||||
|
||||
const tableMode = ref(false)
|
||||
|
||||
function toggleTableMode() {
|
||||
tableMode.value = !tableMode.value
|
||||
}
|
||||
|
||||
function openImagePicker() {
|
||||
document.getElementById('tiptap__image-upload').click()
|
||||
}
|
||||
|
||||
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(--white);
|
||||