224 lines
7.3 KiB
JavaScript
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
|