add rich text editor
Replace the admin rich text textarea with a Tiptap editor while keeping the existing HTML string save contract. Add public-page styles for the editor output.
This commit is contained in:
parent
cdc29b795a
commit
b4b3927ab1
5 changed files with 1735 additions and 13 deletions
970
frontend/rabbi_gerzi/src/components/RichTextEditor.vue
Normal file
970
frontend/rabbi_gerzi/src/components/RichTextEditor.vue
Normal file
|
|
@ -0,0 +1,970 @@
|
|||
<script setup lang="ts">
|
||||
import Highlight from '@tiptap/extension-highlight'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import {
|
||||
Table,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@tiptap/extension-table'
|
||||
import TextAlign from '@tiptap/extension-text-align'
|
||||
import Underline from '@tiptap/extension-underline'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import type { Editor, JSONContent } from '@tiptap/core'
|
||||
import { EditorContent, useEditor } from '@tiptap/vue-3'
|
||||
import {
|
||||
AlignCenter,
|
||||
AlignJustify,
|
||||
AlignLeft,
|
||||
AlignRight,
|
||||
Bold,
|
||||
Columns3,
|
||||
Highlighter,
|
||||
Italic,
|
||||
Link2,
|
||||
List,
|
||||
ListOrdered,
|
||||
Minus,
|
||||
Plus,
|
||||
Quote,
|
||||
Redo2,
|
||||
Rows3,
|
||||
Strikethrough,
|
||||
Table2,
|
||||
TableCellsMerge,
|
||||
TableCellsSplit,
|
||||
Trash2,
|
||||
Underline as UnderlineIcon,
|
||||
Undo2,
|
||||
Unlink2,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
disabled: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
type HeadingLevel = 2 | 3 | 4
|
||||
type TextAlignment = 'left' | 'center' | 'right' | 'justify'
|
||||
|
||||
const allowedLinkProtocols = ['http:', 'https:', 'mailto:', 'tel:']
|
||||
|
||||
const editor = useEditor({
|
||||
content: props.modelValue,
|
||||
editable: !props.disabled,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: {
|
||||
levels: [2, 3, 4],
|
||||
},
|
||||
link: false,
|
||||
underline: false,
|
||||
}),
|
||||
Underline,
|
||||
Highlight,
|
||||
Link.configure({
|
||||
autolink: true,
|
||||
defaultProtocol: 'https',
|
||||
HTMLAttributes: {
|
||||
rel: 'noreferrer',
|
||||
target: '_blank',
|
||||
},
|
||||
isAllowedUri: (linkUrl) => {
|
||||
return isAllowedLinkUrl(linkUrl)
|
||||
},
|
||||
linkOnPaste: true,
|
||||
openOnClick: false,
|
||||
protocols: ['http', 'https', 'mailto', 'tel'],
|
||||
shouldAutoLink: (linkUrl) => {
|
||||
return isAllowedLinkUrl(linkUrl)
|
||||
},
|
||||
}),
|
||||
TextAlign.configure({
|
||||
types: ['heading', 'paragraph'],
|
||||
}),
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
}),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
'aria-label': 'Rich text editor',
|
||||
class: 'rich-text-editor__surface',
|
||||
'data-cy': 'admin-element-rich-text',
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor: updatedEditor }) => {
|
||||
emit('update:modelValue', getEditorHtml(updatedEditor))
|
||||
},
|
||||
})
|
||||
|
||||
const currentBlockType = computed(() => {
|
||||
const editorInstance = editor.value
|
||||
if (editorInstance === undefined) {
|
||||
return 'paragraph'
|
||||
}
|
||||
|
||||
if (editorInstance.isActive('heading', { level: 2 })) {
|
||||
return 'heading-2'
|
||||
}
|
||||
|
||||
if (editorInstance.isActive('heading', { level: 3 })) {
|
||||
return 'heading-3'
|
||||
}
|
||||
|
||||
if (editorInstance.isActive('heading', { level: 4 })) {
|
||||
return 'heading-4'
|
||||
}
|
||||
|
||||
return 'paragraph'
|
||||
})
|
||||
|
||||
const isInTable = computed(() => {
|
||||
const editorInstance = editor.value
|
||||
if (editorInstance === undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
return editorInstance.isActive('table')
|
||||
|| editorInstance.isActive('tableCell')
|
||||
|| editorInstance.isActive('tableHeader')
|
||||
|| editorInstance.isActive('tableRow')
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(currentValue) => {
|
||||
const editorInstance = editor.value
|
||||
if (editorInstance === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
if (getEditorHtml(editorInstance) === currentValue) {
|
||||
return
|
||||
}
|
||||
|
||||
editorInstance.commands.setContent(currentValue, {
|
||||
emitUpdate: false,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.disabled,
|
||||
(isDisabled) => {
|
||||
const editorInstance = editor.value
|
||||
if (editorInstance === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
editorInstance.setEditable(!isDisabled)
|
||||
},
|
||||
)
|
||||
|
||||
function getEditorHtml(editorInstance: Editor): string {
|
||||
if (editorInstance.isEmpty) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return editorInstance.getHTML()
|
||||
}
|
||||
|
||||
function isAllowedLinkUrl(linkUrl: string): boolean {
|
||||
const normalizedUrl = normalizeLinkUrl(linkUrl)
|
||||
|
||||
return normalizedUrl !== null
|
||||
}
|
||||
|
||||
function normalizeLinkUrl(linkUrl: string): string | null {
|
||||
const trimmedUrl = linkUrl.trim()
|
||||
if (trimmedUrl === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
let parsedUrl: URL
|
||||
try {
|
||||
parsedUrl = new URL(trimmedUrl)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!allowedLinkProtocols.includes(parsedUrl.protocol)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return parsedUrl.toString()
|
||||
}
|
||||
|
||||
function isEditorDisabled(): boolean {
|
||||
return props.disabled || editor.value === undefined
|
||||
}
|
||||
|
||||
function isMarkActive(markName: string): boolean {
|
||||
const editorInstance = editor.value
|
||||
if (editorInstance === undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
return editorInstance.isActive(markName)
|
||||
}
|
||||
|
||||
function isAlignmentActive(alignment: TextAlignment): boolean {
|
||||
const editorInstance = editor.value
|
||||
if (editorInstance === undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
return editorInstance.isActive({ textAlign: alignment })
|
||||
}
|
||||
|
||||
function handleBlockTypeChange(changeEvent: Event): void {
|
||||
const target = changeEvent.target
|
||||
if (!(target instanceof HTMLSelectElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (target.value === 'paragraph') {
|
||||
setParagraph()
|
||||
return
|
||||
}
|
||||
|
||||
if (target.value === 'heading-2') {
|
||||
setHeading(2)
|
||||
return
|
||||
}
|
||||
|
||||
if (target.value === 'heading-3') {
|
||||
setHeading(3)
|
||||
return
|
||||
}
|
||||
|
||||
if (target.value === 'heading-4') {
|
||||
setHeading(4)
|
||||
}
|
||||
}
|
||||
|
||||
function setParagraph(): void {
|
||||
editor.value?.chain().focus().setParagraph().run()
|
||||
}
|
||||
|
||||
function setHeading(headingLevel: HeadingLevel): void {
|
||||
editor.value?.chain().focus().setHeading({ level: headingLevel }).run()
|
||||
}
|
||||
|
||||
function toggleBold(): void {
|
||||
editor.value?.chain().focus().toggleBold().run()
|
||||
}
|
||||
|
||||
function toggleItalic(): void {
|
||||
editor.value?.chain().focus().toggleItalic().run()
|
||||
}
|
||||
|
||||
function toggleUnderline(): void {
|
||||
editor.value?.chain().focus().toggleUnderline().run()
|
||||
}
|
||||
|
||||
function toggleStrike(): void {
|
||||
editor.value?.chain().focus().toggleStrike().run()
|
||||
}
|
||||
|
||||
function toggleHighlight(): void {
|
||||
editor.value?.chain().focus().toggleHighlight().run()
|
||||
}
|
||||
|
||||
function setTextAlign(alignment: TextAlignment): void {
|
||||
editor.value?.chain().focus().setTextAlign(alignment).run()
|
||||
}
|
||||
|
||||
function toggleBulletList(): void {
|
||||
editor.value?.chain().focus().toggleBulletList().run()
|
||||
}
|
||||
|
||||
function toggleOrderedList(): void {
|
||||
editor.value?.chain().focus().toggleOrderedList().run()
|
||||
}
|
||||
|
||||
function toggleBlockquote(): void {
|
||||
editor.value?.chain().focus().toggleBlockquote().run()
|
||||
}
|
||||
|
||||
function setHorizontalRule(): void {
|
||||
editor.value?.chain().focus().setHorizontalRule().run()
|
||||
}
|
||||
|
||||
function applyLink(): void {
|
||||
const editorInstance = editor.value
|
||||
if (editorInstance === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const linkUrl = window.prompt('Link URL', currentLinkHref(editorInstance))
|
||||
if (linkUrl === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedUrl = normalizeLinkUrl(linkUrl)
|
||||
if (normalizedUrl === null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (editorInstance.state.selection.empty) {
|
||||
insertLinkedText(editorInstance, normalizedUrl)
|
||||
return
|
||||
}
|
||||
|
||||
editorInstance
|
||||
.chain()
|
||||
.focus()
|
||||
.extendMarkRange('link')
|
||||
.setLink({ href: normalizedUrl })
|
||||
.run()
|
||||
}
|
||||
|
||||
function currentLinkHref(editorInstance: Editor): string {
|
||||
const linkAttributes = editorInstance.getAttributes('link') as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
const href = linkAttributes.href
|
||||
|
||||
return typeof href === 'string' ? href : ''
|
||||
}
|
||||
|
||||
function insertLinkedText(editorInstance: Editor, linkUrl: string): void {
|
||||
const linkedText: JSONContent = {
|
||||
marks: [
|
||||
{
|
||||
attrs: {
|
||||
href: linkUrl,
|
||||
},
|
||||
type: 'link',
|
||||
},
|
||||
],
|
||||
text: linkUrl,
|
||||
type: 'text',
|
||||
}
|
||||
|
||||
editorInstance.chain().focus().insertContent(linkedText).run()
|
||||
}
|
||||
|
||||
function unsetLink(): void {
|
||||
editor.value?.chain().focus().unsetLink().run()
|
||||
}
|
||||
|
||||
function undo(): void {
|
||||
editor.value?.chain().focus().undo().run()
|
||||
}
|
||||
|
||||
function redo(): void {
|
||||
editor.value?.chain().focus().redo().run()
|
||||
}
|
||||
|
||||
function insertTable(): void {
|
||||
editor.value
|
||||
?.chain()
|
||||
.focus()
|
||||
.insertTable({
|
||||
cols: 3,
|
||||
rows: 3,
|
||||
withHeaderRow: true,
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
function addColumnAfter(): void {
|
||||
editor.value?.chain().focus().addColumnAfter().run()
|
||||
}
|
||||
|
||||
function deleteColumn(): void {
|
||||
editor.value?.chain().focus().deleteColumn().run()
|
||||
}
|
||||
|
||||
function addRowAfter(): void {
|
||||
editor.value?.chain().focus().addRowAfter().run()
|
||||
}
|
||||
|
||||
function deleteRow(): void {
|
||||
editor.value?.chain().focus().deleteRow().run()
|
||||
}
|
||||
|
||||
function toggleHeaderRow(): void {
|
||||
editor.value?.chain().focus().toggleHeaderRow().run()
|
||||
}
|
||||
|
||||
function mergeOrSplitCells(): void {
|
||||
editor.value?.chain().focus().mergeOrSplit().run()
|
||||
}
|
||||
|
||||
function splitCell(): void {
|
||||
editor.value?.chain().focus().splitCell().run()
|
||||
}
|
||||
|
||||
function deleteTable(): void {
|
||||
editor.value?.chain().focus().deleteTable().run()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rich-text-editor">
|
||||
<div class="rich-text-editor__toolbar" aria-label="Rich text controls">
|
||||
<select
|
||||
class="rich-text-editor__select"
|
||||
data-cy="admin-rich-text-block-type"
|
||||
:disabled="isEditorDisabled()"
|
||||
:value="currentBlockType"
|
||||
aria-label="Block type"
|
||||
@change="handleBlockTypeChange"
|
||||
>
|
||||
<option value="paragraph">Paragraph</option>
|
||||
<option value="heading-2">Heading 2</option>
|
||||
<option value="heading-3">Heading 3</option>
|
||||
<option value="heading-4">Heading 4</option>
|
||||
</select>
|
||||
|
||||
<div class="rich-text-editor__button-group">
|
||||
<button
|
||||
class="rich-text-editor__button"
|
||||
data-cy="admin-rich-text-bold"
|
||||
type="button"
|
||||
:aria-pressed="isMarkActive('bold')"
|
||||
:class="{ 'rich-text-editor__button--active': isMarkActive('bold') }"
|
||||
:disabled="isEditorDisabled()"
|
||||
aria-label="Bold"
|
||||
title="Bold"
|
||||
@click="toggleBold"
|
||||
>
|
||||
<Bold :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="rich-text-editor__button"
|
||||
type="button"
|
||||
:aria-pressed="isMarkActive('italic')"
|
||||
:class="{
|
||||
'rich-text-editor__button--active': isMarkActive('italic'),
|
||||
}"
|
||||
:disabled="isEditorDisabled()"
|
||||
aria-label="Italic"
|
||||
title="Italic"
|
||||
@click="toggleItalic"
|
||||
>
|
||||
<Italic :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="rich-text-editor__button"
|
||||
type="button"
|
||||
:aria-pressed="isMarkActive('underline')"
|
||||
:class="{
|
||||
'rich-text-editor__button--active': isMarkActive('underline'),
|
||||
}"
|
||||
:disabled="isEditorDisabled()"
|
||||
aria-label="Underline"
|
||||
title="Underline"
|
||||
@click="toggleUnderline"
|
||||
>
|
||||
<UnderlineIcon :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="rich-text-editor__button"
|
||||
type="button"
|
||||
:aria-pressed="isMarkActive('strike')"
|
||||
:class="{
|
||||
'rich-text-editor__button--active': isMarkActive('strike'),
|
||||
}"
|
||||
:disabled="isEditorDisabled()"
|
||||
aria-label="Strike"
|
||||
title="Strike"
|
||||
@click="toggleStrike"
|
||||
>
|
||||
<Strikethrough :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="rich-text-editor__button"
|
||||
type="button"
|
||||
:aria-pressed="isMarkActive('highlight')"
|
||||
:class="{
|
||||
'rich-text-editor__button--active': isMarkActive('highlight'),
|
||||
}"
|
||||
:disabled="isEditorDisabled()"
|
||||
aria-label="Highlight"
|
||||
title="Highlight"
|
||||
@click="toggleHighlight"
|
||||
>
|
||||
<Highlighter :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rich-text-editor__button-group">
|
||||
<button
|
||||
class="rich-text-editor__button"
|
||||
type="button"
|
||||
:aria-pressed="isAlignmentActive('left')"
|
||||
:class="{
|
||||
'rich-text-editor__button--active': isAlignmentActive('left'),
|
||||
}"
|
||||
:disabled="isEditorDisabled()"
|
||||
aria-label="Align left"
|
||||
title="Align left"
|
||||
@click="setTextAlign('left')"
|
||||
>
|
||||
<AlignLeft :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="rich-text-editor__button"
|
||||
type="button"
|
||||
:aria-pressed="isAlignmentActive('center')"
|
||||
:class="{
|
||||
'rich-text-editor__button--active': isAlignmentActive('center'),
|
||||
}"
|
||||
:disabled="isEditorDisabled()"
|
||||
aria-label="Align center"
|
||||
title="Align center"
|
||||
@click="setTextAlign('center')"
|
||||
>
|
||||
<AlignCenter :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="rich-text-editor__button"
|
||||
type="button"
|
||||
:aria-pressed="isAlignmentActive('right')"
|
||||
:class="{
|
||||
'rich-text-editor__button--active': isAlignmentActive('right'),
|
||||
}"
|
||||
:disabled="isEditorDisabled()"
|
||||
aria-label="Align right"
|
||||
title="Align right"
|
||||
@click="setTextAlign('right')"
|
||||
>
|
||||
<AlignRight :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="rich-text-editor__button"
|
||||
type="button"
|
||||
:aria-pressed="isAlignmentActive('justify')"
|
||||
:class="{
|
||||
'rich-text-editor__button--active': isAlignmentActive('justify'),
|
||||
}"
|
||||
:disabled="isEditorDisabled()"
|
||||
aria-label="Justify"
|
||||
title="Justify"
|
||||
@click="setTextAlign('justify')"
|
||||
>
|
||||
<AlignJustify :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rich-text-editor__button-group">
|
||||
<button
|
||||
class="rich-text-editor__button"
|
||||
data-cy="admin-rich-text-bullet-list"
|
||||
type="button"
|
||||
:aria-pressed="isMarkActive('bulletList')"
|
||||
:class="{
|
||||
'rich-text-editor__button--active': isMarkActive('bulletList'),
|
||||
}"
|
||||
:disabled="isEditorDisabled()"
|
||||
aria-label="Bullet list"
|
||||
title="Bullet list"
|
||||
@click="toggleBulletList"
|
||||
>
|
||||
<List :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="rich-text-editor__button"
|
||||
type="button"
|
||||
:aria-pressed="isMarkActive('orderedList')"
|
||||
:class="{
|
||||
'rich-text-editor__button--active': isMarkActive('orderedList'),
|
||||
}"
|
||||
:disabled="isEditorDisabled()"
|
||||
aria-label="Ordered list"
|
||||
title="Ordered list"
|
||||
@click="toggleOrderedList"
|
||||
>
|
||||
<ListOrdered :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="rich-text-editor__button"
|
||||
type="button"
|
||||
:aria-pressed="isMarkActive('blockquote')"
|
||||
:class="{
|
||||
'rich-text-editor__button--active': isMarkActive('blockquote'),
|
||||
}"
|
||||
:disabled="isEditorDisabled()"
|
||||
aria-label="Blockquote"
|
||||
title="Blockquote"
|
||||
@click="toggleBlockquote"
|
||||
>
|
||||
<Quote :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="rich-text-editor__button"
|
||||
type="button"
|
||||
:disabled="isEditorDisabled()"
|
||||
aria-label="Horizontal rule"
|
||||
title="Horizontal rule"
|
||||
@click="setHorizontalRule"
|
||||
>
|
||||
<Minus :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rich-text-editor__button-group">
|
||||
<button
|
||||
class="rich-text-editor__button"
|
||||
data-cy="admin-rich-text-link"
|
||||
type="button"
|
||||
:aria-pressed="isMarkActive('link')"
|
||||
:class="{ 'rich-text-editor__button--active': isMarkActive('link') }"
|
||||
:disabled="isEditorDisabled()"
|
||||
aria-label="Link"
|
||||
title="Link"
|
||||
@click="applyLink"
|
||||
>
|
||||
<Link2 :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="rich-text-editor__button"
|
||||
type="button"
|
||||
:disabled="isEditorDisabled()"
|
||||
aria-label="Remove link"
|
||||
title="Remove link"
|
||||
@click="unsetLink"
|
||||
>
|
||||
<Unlink2 :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rich-text-editor__button-group">
|
||||
<button
|
||||
class="rich-text-editor__button"
|
||||
data-cy="admin-rich-text-insert-table"
|
||||
type="button"
|
||||
:disabled="isEditorDisabled()"
|
||||
aria-label="Insert table"
|
||||
title="Insert table"
|
||||
@click="insertTable"
|
||||
>
|
||||
<Table2 :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="rich-text-editor__button"
|
||||
type="button"
|
||||
:disabled="isEditorDisabled() || !isInTable"
|
||||
aria-label="Add column"
|
||||
title="Add column"
|
||||
@click="addColumnAfter"
|
||||
>
|
||||
<Columns3 :size="16" aria-hidden="true" />
|
||||
<Plus :size="11" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="rich-text-editor__button"
|
||||
type="button"
|
||||
:disabled="isEditorDisabled() || !isInTable"
|
||||
aria-label="Delete column"
|
||||
title="Delete column"
|
||||
@click="deleteColumn"
|
||||
>
|
||||
<Columns3 :size="16" aria-hidden="true" />
|
||||
<Trash2 :size="11" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="rich-text-editor__button"
|
||||
type="button"
|
||||
:disabled="isEditorDisabled() || !isInTable"
|
||||
aria-label="Add row"
|
||||
title="Add row"
|
||||
@click="addRowAfter"
|
||||
>
|
||||
<Rows3 :size="16" aria-hidden="true" />
|
||||
<Plus :size="11" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="rich-text-editor__button"
|
||||
type="button"
|
||||
:disabled="isEditorDisabled() || !isInTable"
|
||||
aria-label="Delete row"
|
||||
title="Delete row"
|
||||
@click="deleteRow"
|
||||
>
|
||||
<Rows3 :size="16" aria-hidden="true" />
|
||||
<Trash2 :size="11" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="rich-text-editor__button"
|
||||
type="button"
|
||||
:disabled="isEditorDisabled() || !isInTable"
|
||||
aria-label="Toggle header row"
|
||||
title="Toggle header row"
|
||||
@click="toggleHeaderRow"
|
||||
>
|
||||
<Rows3 :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="rich-text-editor__button"
|
||||
type="button"
|
||||
:disabled="isEditorDisabled() || !isInTable"
|
||||
aria-label="Merge cells"
|
||||
title="Merge cells"
|
||||
@click="mergeOrSplitCells"
|
||||
>
|
||||
<TableCellsMerge :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="rich-text-editor__button"
|
||||
type="button"
|
||||
:disabled="isEditorDisabled() || !isInTable"
|
||||
aria-label="Split cell"
|
||||
title="Split cell"
|
||||
@click="splitCell"
|
||||
>
|
||||
<TableCellsSplit :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="rich-text-editor__button"
|
||||
type="button"
|
||||
:disabled="isEditorDisabled() || !isInTable"
|
||||
aria-label="Delete table"
|
||||
title="Delete table"
|
||||
@click="deleteTable"
|
||||
>
|
||||
<Trash2 :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rich-text-editor__button-group">
|
||||
<button
|
||||
class="rich-text-editor__button"
|
||||
type="button"
|
||||
:disabled="isEditorDisabled()"
|
||||
aria-label="Undo"
|
||||
title="Undo"
|
||||
@click="undo"
|
||||
>
|
||||
<Undo2 :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="rich-text-editor__button"
|
||||
type="button"
|
||||
:disabled="isEditorDisabled()"
|
||||
aria-label="Redo"
|
||||
title="Redo"
|
||||
@click="redo"
|
||||
>
|
||||
<Redo2 :size="16" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EditorContent :editor="editor" class="rich-text-editor__content" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rich-text-editor {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-white);
|
||||
}
|
||||
|
||||
.rich-text-editor:focus-within {
|
||||
border-color: var(--color-slate);
|
||||
}
|
||||
|
||||
.rich-text-editor__toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.55rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: #fbfaf6;
|
||||
}
|
||||
|
||||
.rich-text-editor__select {
|
||||
min-height: 2.2rem;
|
||||
padding: 0 0.55rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text);
|
||||
background: var(--color-white);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.rich-text-editor__button-group {
|
||||
display: inline-flex;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-white);
|
||||
}
|
||||
|
||||
.rich-text-editor__button {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.15rem;
|
||||
height: 2.15rem;
|
||||
padding: 0 0.45rem;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.rich-text-editor__button:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.rich-text-editor__button:hover:not(:disabled),
|
||||
.rich-text-editor__button:focus-visible,
|
||||
.rich-text-editor__button--active {
|
||||
color: var(--color-white);
|
||||
background: var(--color-slate);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.rich-text-editor__button:disabled,
|
||||
.rich-text-editor__select:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.rich-text-editor__content {
|
||||
background: var(--color-white);
|
||||
}
|
||||
|
||||
.rich-text-editor__content :deep(.rich-text-editor__surface) {
|
||||
min-height: 15rem;
|
||||
padding: 0.85rem 0.95rem;
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.98rem;
|
||||
line-height: 1.65;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.rich-text-editor__content :deep(.rich-text-editor__surface > *:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.rich-text-editor__content :deep(.rich-text-editor__surface > *:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.rich-text-editor__content :deep(h2),
|
||||
.rich-text-editor__content :deep(h3),
|
||||
.rich-text-editor__content :deep(h4) {
|
||||
margin: 1.1rem 0 0.45rem;
|
||||
color: #2c2c2c;
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.rich-text-editor__content :deep(h2) {
|
||||
font-size: 1.55rem;
|
||||
}
|
||||
|
||||
.rich-text-editor__content :deep(h3) {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.rich-text-editor__content :deep(h4) {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.rich-text-editor__content :deep(p) {
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.rich-text-editor__content :deep(ul),
|
||||
.rich-text-editor__content :deep(ol) {
|
||||
margin: 0 0 0.85rem;
|
||||
padding-left: 1.45rem;
|
||||
}
|
||||
|
||||
.rich-text-editor__content :deep(blockquote) {
|
||||
margin: 1rem 0;
|
||||
padding: 0.1rem 0 0.1rem 1rem;
|
||||
border-left: 3px solid var(--color-olive);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.rich-text-editor__content :deep(mark) {
|
||||
padding: 0 0.16em;
|
||||
background: #fff3a7;
|
||||
}
|
||||
|
||||
.rich-text-editor__content :deep(a) {
|
||||
color: var(--color-slate);
|
||||
font-weight: 600;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.16em;
|
||||
}
|
||||
|
||||
.rich-text-editor__content :deep(hr) {
|
||||
margin: 1.2rem 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.rich-text-editor__content :deep(.tableWrapper) {
|
||||
max-width: 100%;
|
||||
margin: 1rem 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.rich-text-editor__content :deep(table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.rich-text-editor__content :deep(th),
|
||||
.rich-text-editor__content :deep(td) {
|
||||
min-width: 4rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border: 1px solid var(--color-border);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.rich-text-editor__content :deep(th) {
|
||||
background: #f6f3eb;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rich-text-editor__content :deep(.selectedCell) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rich-text-editor__content :deep(.selectedCell::after) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
content: '';
|
||||
background: rgb(43 63 78 / 12%);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.rich-text-editor__toolbar {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.rich-text-editor__select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
import { storeToRefs } from 'pinia'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import RichTextEditor from '@/components/RichTextEditor.vue'
|
||||
import SiteHeader from '@/components/SiteHeader.vue'
|
||||
import { useElementsStore } from '@/stores/elements'
|
||||
|
||||
|
|
@ -325,15 +326,10 @@ function resetInput(changeEvent: Event): void {
|
|||
</p>
|
||||
</section>
|
||||
|
||||
<label class="admin-element-page__field">
|
||||
<span class="admin-element-page__label">Rich Text HTML</span>
|
||||
<textarea
|
||||
v-model="form.richText"
|
||||
class="admin-element-page__textarea admin-element-page__code"
|
||||
data-cy="admin-element-rich-text"
|
||||
rows="9"
|
||||
/>
|
||||
</label>
|
||||
<div class="admin-element-page__field">
|
||||
<span class="admin-element-page__label">Rich Text</span>
|
||||
<RichTextEditor v-model="form.richText" :disabled="isSaving" />
|
||||
</div>
|
||||
|
||||
<section class="admin-element-page__media-field">
|
||||
<span class="admin-element-page__label">Short PDF</span>
|
||||
|
|
@ -559,10 +555,6 @@ function resetInput(changeEvent: Event): void {
|
|||
resize: vertical;
|
||||
}
|
||||
|
||||
.admin-element-page__code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.admin-element-page__icon-preview {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
|
|
|
|||
|
|
@ -352,11 +352,93 @@ function isShortYoutubeHost(hostname: string): boolean {
|
|||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.element-page__rich-text :deep(h2),
|
||||
.element-page__rich-text :deep(h3),
|
||||
.element-page__rich-text :deep(h4) {
|
||||
margin: 1.6rem 0 0.65rem;
|
||||
color: #2c2c2c;
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.element-page__rich-text :deep(h2) {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.element-page__rich-text :deep(h3) {
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
|
||||
.element-page__rich-text :deep(h4) {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.element-page__rich-text :deep(ul),
|
||||
.element-page__rich-text :deep(ol) {
|
||||
margin: 0 0 1rem;
|
||||
padding-left: 1.45rem;
|
||||
}
|
||||
|
||||
.element-page__rich-text :deep(li) {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.element-page__rich-text :deep(blockquote) {
|
||||
margin: 1.35rem 0;
|
||||
padding: 0.2rem 0 0.2rem 1.1rem;
|
||||
border-left: 3px solid var(--color-olive);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.element-page__rich-text :deep(strong) {
|
||||
color: #2c2c2c;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.element-page__rich-text :deep(mark) {
|
||||
padding: 0 0.16em;
|
||||
background: #fff3a7;
|
||||
}
|
||||
|
||||
.element-page__rich-text :deep(a) {
|
||||
color: var(--color-slate);
|
||||
font-weight: 600;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.16em;
|
||||
}
|
||||
|
||||
.element-page__rich-text :deep(hr) {
|
||||
margin: 1.5rem 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.element-page__rich-text :deep(.tableWrapper) {
|
||||
max-width: 100%;
|
||||
margin: 1.4rem 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.element-page__rich-text :deep(table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.element-page__rich-text :deep(th),
|
||||
.element-page__rich-text :deep(td) {
|
||||
min-width: 4rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border: 1px solid var(--color-border);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.element-page__rich-text :deep(th) {
|
||||
background: #f6f3eb;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.element-page__youtube {
|
||||
aspect-ratio: 16 / 9;
|
||||
margin-top: 1.75rem;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue