Kansho/frontend/src/components/JournalDocumentEditor.jsx
2026-08-25 13:57:23 +02:00

224 lines
7.3 KiB
JavaScript

import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
import { EditorContent, useEditor, useEditorState } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Placeholder from '@tiptap/extension-placeholder'
import { docToMarkdown, markdownToDoc, unplacedMedia } from '../journal/document.js'
import { JournalMediaContext, KanshoMedia } from '../journal/KanshoMedia.jsx'
function ToolbarButton({ active, children, disabled, onClick }) {
return (
<button type="button" className={active ? 'ghost active' : 'ghost'} disabled={disabled} onClick={onClick}>
{children}
</button>
)
}
const JournalDocumentEditor = forwardRef(function JournalDocumentEditor(
{ body, contentKey, onChange, media, previews, onUpload, onPlaced, onRemoveMedia, disabled },
ref
) {
const imageInput = useRef(null)
const videoInput = useRef(null)
const editorRef = useRef(null)
const editor = useEditor({
immediatelyRender: false,
shouldRerenderOnTransaction: true,
editable: !disabled,
extensions: [
StarterKit.configure({
code: false,
codeBlock: false,
blockquote: false,
link: false,
horizontalRule: false,
heading: { levels: [2, 3] }
}),
Placeholder.configure({ placeholder: 'Schreiben …' }),
KanshoMedia
],
content: '',
onUpdate: ({ editor: instance }) => {
onChange(docToMarkdown(instance.getJSON()))
}
})
editorRef.current = editor
useImperativeHandle(ref, () => ({
getMarkdown: () => {
const instance = editorRef.current
if (!instance) return body || ''
return docToMarkdown(instance.getJSON())
}
}), [body])
useEffect(() => {
if (!editor) return
editor.setEditable(!disabled)
}, [editor, disabled])
useEffect(() => {
if (!editor) return
editor.commands.setContent(markdownToDoc(body || ''), { emitUpdate: false })
}, [editor, contentKey])
const marks = useEditorState({
editor,
selector: ({ editor: instance }) =>
instance
? {
paragraph: instance.isActive('paragraph'),
heading: instance.isActive('heading', { level: 2 }),
subheading: instance.isActive('heading', { level: 3 }),
bold: instance.isActive('bold'),
italic: instance.isActive('italic'),
underline: instance.isActive('underline'),
bullet: instance.isActive('bulletList'),
ordered: instance.isActive('orderedList')
}
: {}
})
const insertFile = async (event) => {
const file = event.target.files?.[0]
event.target.value = ''
if (!file || !editor) return
const asset = await onUpload(file)
if (!asset?.id) return
editor.chain().focus().insertKanshoMedia({
id: asset.id,
caption: '',
kind: asset.kind || (file.type.startsWith('video/') ? 'video' : 'image')
}).run()
await onPlaced?.()
}
const insertExisting = async (item) => {
if (!editor) return
editor.chain().focus().insertKanshoMedia({
id: item.id,
caption: '',
kind: item.kind || 'image'
}).run()
await onPlaced?.()
}
const unused = unplacedMedia(editor ? docToMarkdown(editor.getJSON()) : body, media)
return (
<JournalMediaContext.Provider value={{ previews, media, onRemoveMedia }}>
<div className="journal-doc">
<div className="journal-toolbar" role="toolbar" aria-label="Textformat">
<ToolbarButton
disabled={disabled || !editor}
active={marks?.paragraph}
onClick={() => editor.chain().focus().setParagraph().run()}
>
Absatz
</ToolbarButton>
<ToolbarButton
disabled={disabled || !editor}
active={marks?.heading}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
>
Überschrift
</ToolbarButton>
<ToolbarButton
disabled={disabled || !editor}
active={marks?.subheading}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
>
Zwischenüberschrift
</ToolbarButton>
<ToolbarButton
disabled={disabled || !editor}
active={marks?.bold}
onClick={() => editor.chain().focus().toggleBold().run()}
>
Fett
</ToolbarButton>
<ToolbarButton
disabled={disabled || !editor}
active={marks?.italic}
onClick={() => editor.chain().focus().toggleItalic().run()}
>
Kursiv
</ToolbarButton>
<ToolbarButton
disabled={disabled || !editor}
active={marks?.underline}
onClick={() => editor.chain().focus().toggleUnderline().run()}
>
Unterstrichen
</ToolbarButton>
<ToolbarButton
disabled={disabled || !editor}
active={marks?.bullet}
onClick={() => editor.chain().focus().toggleBulletList().run()}
>
Liste
</ToolbarButton>
<ToolbarButton
disabled={disabled || !editor}
active={marks?.ordered}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
>
Nummerierung
</ToolbarButton>
<ToolbarButton disabled={disabled || !editor} onClick={() => imageInput.current?.click()}>
Bild
</ToolbarButton>
<ToolbarButton disabled={disabled || !editor} onClick={() => videoInput.current?.click()}>
Video
</ToolbarButton>
<input
ref={imageInput}
className="visually-hidden"
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
onChange={insertFile}
/>
<input
ref={videoInput}
className="visually-hidden"
type="file"
accept="video/mp4,video/webm,video/quicktime"
onChange={insertFile}
/>
</div>
<div className={`journal-flow ${disabled ? 'disabled' : ''}`}>
<EditorContent editor={editor} />
</div>
{unused.length > 0 && (
<div className="journal-unplaced">
<p className="muted">Hochgeladen, aber nicht im Text. Dieselbe Datei mehrfach zu wählen erzeugt mehrere Uploads im Text duplizieren, statt erneut hochzuladen.</p>
<ul className="stack">
{unused.map((item) => (
<li key={item.id} className="row-item">
<span>{item.filename || item.kind} · {String(item.id).slice(0, 8)}</span>
<span className="row-actions">
<button type="button" className="ghost" disabled={disabled} onClick={() => insertExisting(item)}>
Hier einfügen
</button>
<button
type="button"
className="ghost"
disabled={disabled}
onClick={() => onRemoveMedia?.(item.id)}
>
Verwerfen
</button>
</span>
</li>
))}
</ul>
</div>
)}
</div>
</JournalMediaContext.Provider>
)
})
export default JournalDocumentEditor