461 lines
9.6 KiB
Vue
461 lines
9.6 KiB
Vue
<script setup lang="ts">
|
|
import { t } from '@/i18n'
|
|
|
|
import { computed, ref, shallowRef } from 'vue'
|
|
import type { EditorToolbarItem } from '@nuxt/ui'
|
|
import type { Editor } from '@tiptap/core'
|
|
import LinkModal from '@/components/LinkModal.vue'
|
|
import LinkPopover from '@/components/LinkPopover.vue'
|
|
import { useArticleLink } from '@/composables/useArticleLink'
|
|
import { useScrollEdges } from '@/composables/useScrollEdges'
|
|
import { ArticleLink } from '@/lib/articleLink'
|
|
import { ArticleIndent } from '@/lib/articleIndent'
|
|
import { ArticleTextAlign } from '@/lib/articleAlign'
|
|
import { ArticleImage, findArticleImagePos } from '@/lib/articleImage'
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
modelValue: string
|
|
articleId: number
|
|
disabled?: boolean
|
|
}>(),
|
|
{
|
|
disabled: false,
|
|
},
|
|
)
|
|
|
|
const emit = defineEmits<{
|
|
'update:modelValue': [value: string]
|
|
'request-image': []
|
|
'request-remove-image': [imageId: number]
|
|
}>()
|
|
|
|
const editorInstance = shallowRef<Editor | null>(null)
|
|
const toolbarRef = ref<{ $el: HTMLElement } | null>(null)
|
|
const toolbarElement = computed(() => toolbarRef.value?.$el ?? null)
|
|
const { hiddenBefore, hiddenAfter } = useScrollEdges(toolbarElement)
|
|
const link = useArticleLink(editorInstance, () => props.disabled)
|
|
const extensions = [
|
|
ArticleIndent,
|
|
ArticleTextAlign,
|
|
ArticleLink.configure({
|
|
onRequestDialog: () => link.openDialog(),
|
|
onRequestPopover: (target) => link.openPopover(target),
|
|
}),
|
|
ArticleImage.configure({
|
|
onRequestRemove: (imageId: number) => emit('request-remove-image', imageId),
|
|
}),
|
|
]
|
|
|
|
function captureEditor(editor: Editor) {
|
|
editorInstance.value = editor
|
|
return ''
|
|
}
|
|
|
|
function onUpdate(value: string) {
|
|
emit('update:modelValue', value)
|
|
}
|
|
|
|
function currentBlockLabel(editor: Editor) {
|
|
for (let level = 1; level <= 4; level += 1) {
|
|
if (editor.isActive('heading', { level })) {
|
|
return t('Heading {0}', [level])
|
|
}
|
|
}
|
|
return t('Paragraph')
|
|
}
|
|
|
|
function isDefaultAlignment(editor: Editor) {
|
|
return !['center', 'right', 'justify'].some((align) => editor.isActive({ textAlign: align }))
|
|
}
|
|
|
|
function buildItems(editor: Editor): EditorToolbarItem[][] {
|
|
return [
|
|
[
|
|
{
|
|
kind: 'undo',
|
|
icon: 'i-lucide-undo-2',
|
|
tooltip: {
|
|
get text() {
|
|
return t('Undo')
|
|
},
|
|
},
|
|
},
|
|
{
|
|
kind: 'redo',
|
|
icon: 'i-lucide-redo-2',
|
|
tooltip: {
|
|
get text() {
|
|
return t('Redo')
|
|
},
|
|
},
|
|
},
|
|
],
|
|
[
|
|
{
|
|
icon: 'i-lucide-pilcrow',
|
|
label: currentBlockLabel(editor),
|
|
trailingIcon: 'i-lucide-chevron-down',
|
|
ui: { trailingIcon: 'size-3' },
|
|
tooltip: {
|
|
get text() {
|
|
return t('Paragraph style')
|
|
},
|
|
},
|
|
items: [
|
|
{
|
|
icon: 'i-lucide-pilcrow',
|
|
get label() {
|
|
return t('Paragraph')
|
|
},
|
|
onSelect: () => editor.chain().focus().setParagraph().run(),
|
|
},
|
|
{
|
|
kind: 'heading',
|
|
level: 1,
|
|
icon: 'i-lucide-heading-1',
|
|
get label() {
|
|
return t('Heading 1')
|
|
},
|
|
tooltip: {
|
|
get text() {
|
|
return t('Heading 1')
|
|
},
|
|
},
|
|
},
|
|
{
|
|
kind: 'heading',
|
|
level: 2,
|
|
icon: 'i-lucide-heading-2',
|
|
get label() {
|
|
return t('Heading 2')
|
|
},
|
|
tooltip: {
|
|
get text() {
|
|
return t('Heading 2')
|
|
},
|
|
},
|
|
},
|
|
{
|
|
kind: 'heading',
|
|
level: 3,
|
|
icon: 'i-lucide-heading-3',
|
|
get label() {
|
|
return t('Heading 3')
|
|
},
|
|
tooltip: {
|
|
get text() {
|
|
return t('Heading 3')
|
|
},
|
|
},
|
|
},
|
|
{
|
|
kind: 'heading',
|
|
level: 4,
|
|
icon: 'i-lucide-heading-4',
|
|
get label() {
|
|
return t('Heading 4')
|
|
},
|
|
tooltip: {
|
|
get text() {
|
|
return t('Heading 4')
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
[
|
|
{
|
|
kind: 'mark',
|
|
mark: 'bold',
|
|
icon: 'i-lucide-bold',
|
|
tooltip: {
|
|
get text() {
|
|
return t('Bold')
|
|
},
|
|
},
|
|
},
|
|
{
|
|
kind: 'mark',
|
|
mark: 'italic',
|
|
icon: 'i-lucide-italic',
|
|
tooltip: {
|
|
get text() {
|
|
return t('Italic')
|
|
},
|
|
},
|
|
},
|
|
{
|
|
kind: 'mark',
|
|
mark: 'underline',
|
|
icon: 'i-lucide-underline',
|
|
tooltip: {
|
|
get text() {
|
|
return t('Underline')
|
|
},
|
|
},
|
|
},
|
|
{
|
|
kind: 'mark',
|
|
mark: 'strike',
|
|
icon: 'i-lucide-strikethrough',
|
|
tooltip: {
|
|
get text() {
|
|
return t('Strikethrough')
|
|
},
|
|
},
|
|
},
|
|
],
|
|
[
|
|
{
|
|
kind: 'textAlign',
|
|
align: 'left',
|
|
active: isDefaultAlignment(editor),
|
|
icon: 'i-lucide-align-left',
|
|
tooltip: {
|
|
get text() {
|
|
return t('Align left')
|
|
},
|
|
},
|
|
},
|
|
{
|
|
kind: 'textAlign',
|
|
align: 'center',
|
|
icon: 'i-lucide-align-center',
|
|
tooltip: {
|
|
get text() {
|
|
return t('Align center')
|
|
},
|
|
},
|
|
},
|
|
{
|
|
kind: 'textAlign',
|
|
align: 'right',
|
|
icon: 'i-lucide-align-right',
|
|
tooltip: {
|
|
get text() {
|
|
return t('Align right')
|
|
},
|
|
},
|
|
},
|
|
{
|
|
kind: 'textAlign',
|
|
align: 'justify',
|
|
icon: 'i-lucide-align-justify',
|
|
tooltip: {
|
|
get text() {
|
|
return t('Justify')
|
|
},
|
|
},
|
|
},
|
|
],
|
|
[
|
|
{
|
|
kind: 'bulletList',
|
|
icon: 'i-lucide-list',
|
|
tooltip: {
|
|
get text() {
|
|
return t('Bullet list')
|
|
},
|
|
},
|
|
},
|
|
{
|
|
kind: 'orderedList',
|
|
icon: 'i-lucide-list-ordered',
|
|
tooltip: {
|
|
get text() {
|
|
return t('Numbered list')
|
|
},
|
|
},
|
|
},
|
|
{
|
|
icon: 'i-lucide-indent-decrease',
|
|
tooltip: {
|
|
get text() {
|
|
return t('Outdent')
|
|
},
|
|
},
|
|
onClick: () => editor.chain().focus().outdent().run(),
|
|
},
|
|
{
|
|
icon: 'i-lucide-indent-increase',
|
|
tooltip: {
|
|
get text() {
|
|
return t('Indent')
|
|
},
|
|
},
|
|
onClick: () => editor.chain().focus().indent().run(),
|
|
},
|
|
],
|
|
[
|
|
link.toolbarItem(editor),
|
|
{
|
|
kind: 'clearFormatting',
|
|
icon: 'i-lucide-remove-formatting',
|
|
tooltip: {
|
|
get text() {
|
|
return t('Clear formatting')
|
|
},
|
|
},
|
|
},
|
|
{
|
|
icon: 'i-lucide-image-plus',
|
|
get label() {
|
|
return t('Image')
|
|
},
|
|
tooltip: {
|
|
get text() {
|
|
return t('Add an image')
|
|
},
|
|
},
|
|
onClick: () => emit('request-image'),
|
|
},
|
|
],
|
|
]
|
|
}
|
|
|
|
defineExpose({
|
|
focus() {
|
|
editorInstance.value?.commands.focus()
|
|
},
|
|
insertImage(src: string, alt: string, imageId: number) {
|
|
editorInstance.value?.commands.setArticleImage({ src, alt, imageId })
|
|
},
|
|
imageSrc(imageId: number): string | null {
|
|
const editor = editorInstance.value
|
|
if (!editor) {
|
|
return null
|
|
}
|
|
const pos = findArticleImagePos(editor, imageId)
|
|
if (pos === null) {
|
|
return null
|
|
}
|
|
const src = editor.state.doc.nodeAt(pos)?.attrs.src
|
|
return typeof src === 'string' ? src : null
|
|
},
|
|
removeImage(imageId: number) {
|
|
const editor = editorInstance.value
|
|
if (!editor) {
|
|
return
|
|
}
|
|
const pos = findArticleImagePos(editor, imageId)
|
|
if (pos === null) {
|
|
return
|
|
}
|
|
editor
|
|
.chain()
|
|
.deleteRange({ from: pos, to: pos + 1 })
|
|
.run()
|
|
},
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<UEditor
|
|
v-slot="{ editor }"
|
|
:model-value="props.modelValue"
|
|
:extensions="extensions"
|
|
:starter-kit="{ link: false }"
|
|
:image="false"
|
|
content-type="html"
|
|
:editable="!props.disabled"
|
|
:ui="{ base: 'article-content' }"
|
|
class="article-editor grid h-full min-h-0 grid-rows-[auto_minmax(0,1fr)_auto] grid-cols-[minmax(0,1fr)] lg:grid-cols-[minmax(0,1fr)_17.5rem]"
|
|
@update:model-value="onUpdate"
|
|
>
|
|
<span class="hidden">{{ captureEditor(editor) }}</span>
|
|
<UEditorDragHandle :editor="editor" />
|
|
<div class="relative col-span-full row-start-1 min-w-0">
|
|
<UEditorToolbar
|
|
ref="toolbarRef"
|
|
:editor="editor"
|
|
:items="buildItems(editor)"
|
|
layout="fixed"
|
|
class="article-toolbar min-w-0 overflow-x-auto border-b border-default bg-default px-2 py-1.5 sm:px-6 [&>*]:shrink-0"
|
|
/>
|
|
<span
|
|
v-if="hiddenBefore"
|
|
data-testid="toolbar-fade-start"
|
|
aria-hidden="true"
|
|
class="pointer-events-none absolute inset-y-0 left-0 w-6 bg-linear-to-r from-default to-transparent"
|
|
/>
|
|
<span
|
|
v-if="hiddenAfter"
|
|
data-testid="toolbar-fade-end"
|
|
aria-hidden="true"
|
|
class="pointer-events-none absolute inset-y-0 right-0 w-8 bg-linear-to-l from-default to-transparent"
|
|
/>
|
|
</div>
|
|
<div class="col-start-1 row-start-3">
|
|
<slot name="footer" />
|
|
</div>
|
|
<div class="col-start-2 row-span-2 row-start-2 hidden min-h-0 lg:block">
|
|
<slot name="aside" />
|
|
</div>
|
|
<LinkModal
|
|
v-model:open="link.dialogOpen.value"
|
|
:mode="link.dialog.value.mode"
|
|
:text="link.dialog.value.text"
|
|
:href="link.dialog.value.href"
|
|
:new-tab="link.dialog.value.newTab"
|
|
@submit="link.apply"
|
|
@remove="link.removeFromDialog"
|
|
/>
|
|
<LinkPopover
|
|
v-model:open="link.popoverOpen.value"
|
|
:href="link.popover.value.href"
|
|
:text="link.popover.value.text"
|
|
:reference="link.popover.value.element"
|
|
@edit="link.editFromPopover"
|
|
@open-link="link.openFromPopover"
|
|
@remove="link.removeFromPopover"
|
|
/>
|
|
</UEditor>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.article-editor :deep([data-slot='content']) {
|
|
grid-column-start: 1;
|
|
grid-row-start: 2;
|
|
min-height: 0;
|
|
overflow-y: auto;
|
|
background: var(--ui-bg);
|
|
}
|
|
.article-editor :deep(.ProseMirror) {
|
|
box-sizing: border-box;
|
|
max-width: 48rem;
|
|
padding: 2.5rem 3rem;
|
|
outline: none;
|
|
}
|
|
.article-editor :deep(.ProseMirror ul),
|
|
.article-editor :deep(.ProseMirror ol) {
|
|
padding-left: 1.75rem;
|
|
}
|
|
.article-editor :deep(.ProseMirror li) {
|
|
margin: 0;
|
|
padding-left: 0;
|
|
}
|
|
.article-editor :deep(.ProseMirror h2) {
|
|
margin-top: 2rem;
|
|
}
|
|
.article-toolbar {
|
|
scrollbar-width: none;
|
|
}
|
|
.article-toolbar::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
@media (max-width: 39.999rem) {
|
|
.article-toolbar :deep(button) {
|
|
min-width: 2.5rem;
|
|
min-height: 2.5rem;
|
|
justify-content: center;
|
|
}
|
|
}
|
|
@media (max-width: 47.999rem) {
|
|
.article-editor :deep(.ProseMirror) {
|
|
padding: 1.5rem 1rem;
|
|
}
|
|
}
|
|
</style>
|