Files
sky_phone/frontend/src/components/NotesRichTextEditor.vue
T
Type 96cffcf4d1 FIX - use standard tabbar for note formatting
Migrates the note editor formatting controls to the shared SkyTabBar and SkyTabButton primitives. Removes the app-local toolbar geometry and adds a contract test for the shared component usage.
2026-08-16 00:30:08 +02:00

518 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import Placeholder from '@tiptap/extension-placeholder'
import StarterKit from '@tiptap/starter-kit'
import { Mark, mergeAttributes } from '@tiptap/core'
import { TextSelection } from '@tiptap/pm/state'
import { EditorContent, useEditor } from '@tiptap/vue-3'
import DOMPurify from 'dompurify'
import {
Bold,
Italic,
List,
ListOrdered,
Quote,
Redo2,
Strikethrough,
Underline,
Undo2,
} from 'lucide-vue-next'
import { onBeforeUnmount, watch } from 'vue'
import { SkyIcon, SkyTabBar, SkyTabButton } from '@/ui'
import {
noteBodyToEditorHtml,
serializeRichNoteBody,
} from '@/utils/noteRichText'
export type NotesEditorLabels = {
bold: string
bulletList: string
decreaseText: string
increaseText: string
italic: string
numberedList: string
quote: string
redo: string
strike: string
toolbar: string
underline: string
undo: string
}
const props = defineProps<{
dark: boolean
labels: NotesEditorLabels
modelValue: string
placeholder: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const allowedTags = [
'blockquote',
'br',
'em',
'h2',
'h3',
'li',
'ol',
'p',
's',
'strong',
'span',
'u',
'ul',
]
const noteTextSizeSteps = [
'tiny',
'small',
'compact',
'normal',
'medium',
'large',
'huge',
] as const
type NoteTextSizeStep = (typeof noteTextSizeSteps)[number]
const normalTextSizeIndex = noteTextSizeSteps.indexOf('normal')
const NoteTextSize = Mark.create({
name: 'noteTextSize',
addAttributes() {
return {
size: {
default: null,
parseHTML: (element) => {
const size = element.getAttribute('data-note-size')
return noteTextSizeSteps.includes(size as NoteTextSizeStep) &&
size !== 'normal'
? size
: null
},
renderHTML: (attributes) => {
const size = attributes.size as NoteTextSizeStep | undefined
return size &&
noteTextSizeSteps.includes(size) &&
size !== 'normal'
? { 'data-note-size': size }
: {}
},
},
}
},
parseHTML() {
return [{ tag: 'span[data-note-size]' }]
},
renderHTML({ HTMLAttributes }) {
return ['span', mergeAttributes(HTMLAttributes), 0]
},
})
function sanitizeEditorHtml(body: string): string {
return String(
DOMPurify.sanitize(noteBodyToEditorHtml(body), {
ALLOWED_ATTR: ['data-note-size'],
ALLOWED_TAGS: allowedTags,
}),
)
}
let acceptedHtml = sanitizeEditorHtml(props.modelValue)
const editor = useEditor({
content: acceptedHtml,
extensions: [
StarterKit.configure({
code: false,
codeBlock: false,
heading: { levels: [2, 3] },
horizontalRule: false,
link: false,
strike: {},
underline: {},
}),
NoteTextSize,
Placeholder.configure({ placeholder: props.placeholder }),
],
injectCSS: false,
onUpdate: ({ editor: currentEditor }) => {
const safeHtml = String(
DOMPurify.sanitize(currentEditor.getHTML(), {
ALLOWED_ATTR: ['data-note-size'],
ALLOWED_TAGS: allowedTags,
}),
)
const serializedBody = serializeRichNoteBody(safeHtml)
if (serializedBody.length > 20_000) {
currentEditor.commands.setContent(acceptedHtml, { emitUpdate: false })
return
}
acceptedHtml = safeHtml
emit('update:modelValue', serializedBody)
},
})
function adjustTextSize(direction: -1 | 1): void {
if (!editor.value || editor.value.state.selection.empty) return
const currentSize = editor.value.getAttributes('noteTextSize')
.size as NoteTextSizeStep | undefined
const currentIndex = currentSize
? noteTextSizeSteps.indexOf(currentSize)
: normalTextSizeIndex
const nextIndex = Math.max(
0,
Math.min(
noteTextSizeSteps.length - 1,
(currentIndex >= 0 ? currentIndex : normalTextSizeIndex) + direction,
),
)
const nextSize = noteTextSizeSteps[nextIndex]
const chain = editor.value.chain().focus()
if (nextSize === 'normal') {
chain.unsetMark('noteTextSize').run()
return
}
chain.setMark('noteTextSize', { size: nextSize }).run()
}
function toggleSelectionQuote(): void {
if (!editor.value || editor.value.state.selection.empty) return
const { from, to } = editor.value.state.selection
const { doc, tr } = editor.value.state
const selectedText = doc.textBetween(from, to)
const isQuotedInside = selectedText.startsWith('„') && selectedText.endsWith('“')
const isQuotedOutside =
from > 1 &&
doc.textBetween(from - 1, from) === '„' &&
doc.textBetween(to, to + 1) === '“'
if (isQuotedInside) {
tr.delete(to - 1, to).delete(from, from + 1)
tr.setSelection(TextSelection.create(tr.doc, from, to - 2))
} else if (isQuotedOutside) {
tr.delete(to, to + 1).delete(from - 1, from)
tr.setSelection(TextSelection.create(tr.doc, from - 1, to - 1))
} else {
tr.insertText('“', to).insertText('„', from)
tr.setSelection(TextSelection.create(tr.doc, from + 1, to + 1))
}
editor.value.view.dispatch(tr)
editor.value.view.focus()
}
watch(
() => props.modelValue,
(body) => {
if (!editor.value) return
const safeHtml = sanitizeEditorHtml(body)
if (editor.value.getHTML() === safeHtml) return
acceptedHtml = safeHtml
editor.value.commands.setContent(safeHtml, { emitUpdate: false })
},
)
onBeforeUnmount(() => editor.value?.destroy())
</script>
<template>
<section
class="notes-rich-editor"
:class="{ 'notes-rich-editor--dark': dark }"
>
<EditorContent
v-if="editor"
class="notes-rich-editor__content"
:editor="editor"
/>
<SkyTabBar v-if="editor" :label="labels.toolbar">
<SkyTabButton
:active="
['tiny', 'small', 'compact'].includes(
editor.getAttributes('noteTextSize').size,
)
"
:aria-label="labels.undo"
:disabled="!editor.can().chain().focus().undo().run()"
@click="editor.chain().focus().undo().run()"
>
<template #icon
><SkyIcon :size="19"><Undo2 /></SkyIcon
></template>
</SkyTabButton>
<SkyTabButton
:active="
['medium', 'large', 'huge'].includes(
editor.getAttributes('noteTextSize').size,
)
"
:aria-label="labels.redo"
:disabled="!editor.can().chain().focus().redo().run()"
@click="editor.chain().focus().redo().run()"
>
<template #icon
><SkyIcon :size="19"><Redo2 /></SkyIcon
></template>
</SkyTabButton>
<SkyTabButton
:active="editor.isActive('bold')"
:class="{
'notes-rich-editor__tool--active': [
'tiny',
'small',
'compact',
].includes(editor.getAttributes('noteTextSize').size),
'notes-rich-editor__tool--unavailable': editor.state.selection.empty,
}"
:aria-label="labels.decreaseText"
@pointerdown.prevent="adjustTextSize(-1)"
>
<span class="notes-rich-editor__text-tool">A</span>
</SkyTabButton>
<SkyTabButton
:active="editor.isActive('italic')"
:class="{
'notes-rich-editor__tool--active': [
'medium',
'large',
'huge',
].includes(editor.getAttributes('noteTextSize').size),
'notes-rich-editor__tool--unavailable': editor.state.selection.empty,
}"
:aria-label="labels.increaseText"
@pointerdown.prevent="adjustTextSize(1)"
>
<span
class="notes-rich-editor__text-tool notes-rich-editor__text-tool--large"
>A+</span
>
</SkyTabButton>
<SkyTabButton
:class="{
'notes-rich-editor__tool--active': editor.isActive('bold'),
}"
:aria-label="labels.bold"
@click="editor.chain().focus().toggleBold().run()"
>
<template #icon
><SkyIcon :size="19"><Bold /></SkyIcon
></template>
</SkyTabButton>
<SkyTabButton
:class="{
'notes-rich-editor__tool--active': editor.isActive('italic'),
}"
:aria-label="labels.italic"
@click="editor.chain().focus().toggleItalic().run()"
>
<template #icon
><SkyIcon :size="19"><Italic /></SkyIcon
></template>
</SkyTabButton>
<SkyTabButton
:active="editor.isActive('underline')"
:class="{
'notes-rich-editor__tool--active': editor.isActive('underline'),
}"
:aria-label="labels.underline"
@click="editor.chain().focus().toggleUnderline().run()"
>
<template #icon
><SkyIcon :size="19"><Underline /></SkyIcon
></template>
</SkyTabButton>
<SkyTabButton
:active="editor.isActive('strike')"
:class="{
'notes-rich-editor__tool--active': editor.isActive('strike'),
}"
:aria-label="labels.strike"
@click="editor.chain().focus().toggleStrike().run()"
>
<template #icon
><SkyIcon :size="19"><Strikethrough /></SkyIcon
></template>
</SkyTabButton>
<SkyTabButton
:active="editor.isActive('bulletList')"
:class="{
'notes-rich-editor__tool--active': editor.isActive('bulletList'),
}"
:aria-label="labels.bulletList"
@click="editor.chain().focus().toggleBulletList().run()"
>
<template #icon
><SkyIcon :size="20"><List /></SkyIcon
></template>
</SkyTabButton>
<SkyTabButton
:active="editor.isActive('orderedList')"
:class="{
'notes-rich-editor__tool--active': editor.isActive('orderedList'),
}"
:aria-label="labels.numberedList"
@click="editor.chain().focus().toggleOrderedList().run()"
>
<template #icon
><SkyIcon :size="20"><ListOrdered /></SkyIcon
></template>
</SkyTabButton>
<SkyTabButton
:class="{
'notes-rich-editor__tool--unavailable': editor.state.selection.empty,
}"
:aria-label="labels.quote"
@pointerdown.prevent="toggleSelectionQuote"
>
<template #icon
><SkyIcon :size="19"><Quote /></SkyIcon
></template>
</SkyTabButton>
</SkyTabBar>
</section>
</template>
<style scoped>
.notes-rich-editor {
min-height: 0;
display: flex;
flex: 1;
flex-direction: column;
overflow: hidden;
background: #fff;
color: #171719;
}
.notes-rich-editor--dark {
background: #000;
color: #f5f5f7;
}
.notes-rich-editor__content {
min-height: 0;
flex: 1;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
}
.notes-rich-editor__content::-webkit-scrollbar {
display: none;
}
:deep(.tiptap) {
min-height: 100%;
padding: 13px var(--sky-page-gutter) 90px;
outline: none;
font-size: 17px;
line-height: 1.48;
word-break: break-word;
}
:deep(.tiptap p) {
min-height: 1.48em;
margin: 0 0 0.55em;
}
:deep(.tiptap h2),
:deep(.tiptap h3) {
margin: 0.75em 0 0.35em;
letter-spacing: -0.02em;
line-height: 1.15;
}
:deep(.tiptap h2) {
font-size: 28px;
}
:deep(.tiptap h3) {
font-size: 22px;
}
:deep(.tiptap span[data-note-size='tiny']) {
font-size: 11px;
}
:deep(.tiptap span[data-note-size='small']) {
font-size: 13px;
}
:deep(.tiptap span[data-note-size='compact']) {
font-size: 15px;
}
:deep(.tiptap span[data-note-size='medium']) {
font-size: 20px;
}
:deep(.tiptap span[data-note-size='large']) {
font-size: 23px;
}
:deep(.tiptap span[data-note-size='huge']) {
font-size: 28px;
}
:deep(.tiptap ul),
:deep(.tiptap ol) {
margin: 0.45em 0 0.75em;
padding-left: 1.45em;
list-style-position: outside;
}
:deep(.tiptap ul) {
list-style-type: disc;
}
:deep(.tiptap ol) {
list-style-type: decimal;
}
:deep(.tiptap ul ul) {
list-style-type: circle;
}
:deep(.tiptap ol ol) {
list-style-type: lower-alpha;
}
:deep(.tiptap li) {
margin: 0.2em 0;
}
:deep(.tiptap blockquote) {
margin: 0.65em 0;
padding-left: 0.85em;
border-left: 3px solid #ffcc00;
color: #636366;
}
.notes-rich-editor--dark :deep(.tiptap blockquote) {
color: #a1a1a6;
}
:deep(.tiptap p.is-editor-empty:first-child::before) {
float: left;
height: 0;
color: #8e8e93;
content: attr(data-placeholder);
pointer-events: none;
}
.notes-rich-editor__text-tool {
font-size: 14px;
font-weight: 650;
}
.notes-rich-editor__text-tool--large {
font-size: 17px;
}
</style>