feat(UI-Overhaul-Phase3): Wiki WYSIWYG Editor mit Tiptap

- WikiEditor.tsx: Complete rebuild with Tiptap WYSIWYG editor
  - Notion-style floating toolbar with bold/italic/underline/strike
  - Headings (H1/H2/H3), bullet/ordered lists, blockquote, code blocks
  - Link and image insertion
  - Text alignment (left/center/right)
  - Undo/redo support
  - HTML-to-Markdown conversion for backend storage
  - Markdown-to-HTML conversion for editor initialization
- Wiki.tsx: View/Edit mode toggle
  - View mode: Rendered Markdown (ReactMarkdown)
  - Edit mode: WYSIWYG editor (Tiptap)
  - Inline save button in edit mode
  - wikiMode resets to view when selecting new article
  - handleInlineSave saves article content directly

tsc clean
This commit is contained in:
Agent Zero
2026-08-21 13:51:56 +02:00
parent 7f9a2bca50
commit 6c197e1fc5
2 changed files with 242 additions and 59 deletions
+190 -53
View File
@@ -1,16 +1,27 @@
/** /**
* WikiEditor — Markdown editor with live preview. * WikiEditor — WYSIWYG editor using Tiptap (Notion-style).
* *
* Uses a textarea for Markdown input and react-markdown + remark-gfm for * Features: Floating toolbar, Markdown export for backend storage,
* the live preview pane. Content is stored as Markdown in the backend. * bold/italic/underline/strike, headings, lists, links, code blocks, images.
*/ */
import React, { useMemo, useState } from 'react'; import React, { useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import ReactMarkdown from 'react-markdown'; import { useEditor, EditorContent, Editor } from '@tiptap/react';
import remarkGfm from 'remark-gfm'; import StarterKit from '@tiptap/starter-kit';
import Underline from '@tiptap/extension-underline';
import Link from '@tiptap/extension-link';
import Image from '@tiptap/extension-image';
import Placeholder from '@tiptap/extension-placeholder';
import TextAlign from '@tiptap/extension-text-align';
import { TextStyle } from "@tiptap/extension-text-style";
import Color from '@tiptap/extension-color';
import clsx from 'clsx'; import clsx from 'clsx';
import { Eye, PencilLine, Columns2 } from 'lucide-react'; import {
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Heading1, Heading2, Heading3,
List, ListOrdered, Quote, Code, Link as LinkIcon, Image as ImageIcon,
AlignLeft, AlignCenter, AlignRight, Undo, Redo,
} from 'lucide-react';
export interface WikiEditorProps { export interface WikiEditorProps {
value: string; value: string;
@@ -19,61 +30,187 @@ export interface WikiEditorProps {
minHeight?: string; minHeight?: string;
} }
type EditorMode = 'write' | 'preview' | 'split'; function ToolbarButton({
onClick, isActive, disabled, icon: Icon, label,
export function WikiEditor({ value, onChange, placeholder, minHeight = 'min-h-72' }: WikiEditorProps) { }: {
const { t } = useTranslation(); onClick: () => void;
const [mode, setMode] = useState<EditorMode>('split'); isActive?: boolean;
disabled?: boolean;
const preview = useMemo( icon: React.ComponentType<{ className?: string }>;
() => ( label: string;
<div className="prose prose-sm max-w-none px-4 py-3 text-secondary-800"> }) {
<ReactMarkdown remarkPlugins={[remarkGfm]}>{value || t('wiki.editor.emptyPreview')}</ReactMarkdown> return (
</div>
),
[value, t]
);
const modeButton = (m: EditorMode, label: string, Icon: React.ComponentType<{ className?: string }>) => (
<button <button
type="button" type="button"
onClick={() => setMode(m)} onClick={onClick}
disabled={disabled}
className={clsx( className={clsx(
'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium min-h-touch transition-colors', 'p-1.5 rounded-md transition-colors min-h-touch min-w-touch',
mode === m ? 'bg-primary-600 text-white' : 'text-secondary-600 hover:bg-secondary-100' isActive ? 'bg-primary-100 text-primary-700' : 'text-secondary-600 hover:bg-secondary-100',
disabled && 'opacity-40 cursor-not-allowed',
)} )}
aria-pressed={mode === m} aria-label={label}
title={label}
> >
<Icon className="h-4 w-4" aria-hidden="true" /> <Icon className="h-4 w-4" />
{label}
</button> </button>
); );
}
function Toolbar({ editor }: { editor: Editor | null }) {
const { t } = useTranslation();
if (!editor) return null;
const setLink = useCallback(() => {
const previousUrl = editor.getAttributes('link').href;
const url = window.prompt('URL', previousUrl);
if (url === null) return;
if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
}, [editor]);
const addImage = useCallback(() => {
const url = window.prompt('Bild URL');
if (url) editor.chain().focus().setImage({ src: url }).run();
}, [editor]);
return ( return (
<div className="border border-secondary-300 rounded-md overflow-hidden"> <div className="flex items-center gap-0.5 flex-wrap border-b border-secondary-200 bg-secondary-50 px-2 py-1.5 sticky top-0 z-10">
<div className="flex items-center gap-1 border-b border-secondary-200 bg-secondary-50 px-2 py-1.5"> <ToolbarButton onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} icon={Undo} label={t('common.undo', 'Rückgängig')} />
{modeButton('write', t('editor.write'), PencilLine)} <ToolbarButton onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()} icon={Redo} label={t('common.redo', 'Wiederholen')} />
{modeButton('preview', t('editor.preview'), Eye)} <div className="w-px h-5 bg-secondary-200 mx-1" />
{modeButton('split', t('editor.split'), Columns2)} <ToolbarButton onClick={() => editor.chain().focus().toggleBold().run()} isActive={editor.isActive('bold')} icon={Bold} label="Bold" />
</div> <ToolbarButton onClick={() => editor.chain().focus().toggleItalic().run()} isActive={editor.isActive('italic')} icon={Italic} label="Italic" />
<div className={clsx('grid', mode === 'split' ? 'grid-cols-1 md:grid-cols-2' : 'grid-cols-1')}> <ToolbarButton onClick={() => editor.chain().focus().toggleUnderline().run()} isActive={editor.isActive('underline')} icon={UnderlineIcon} label="Underline" />
{(mode === 'write' || mode === 'split') && ( <ToolbarButton onClick={() => editor.chain().focus().toggleStrike().run()} isActive={editor.isActive('strike')} icon={Strikethrough} label="Strike" />
<textarea <div className="w-px h-5 bg-secondary-200 mx-1" />
value={value} <ToolbarButton onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} isActive={editor.isActive('heading', { level: 1 })} icon={Heading1} label="H1" />
onChange={(e) => onChange(e.target.value)} <ToolbarButton onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} isActive={editor.isActive('heading', { level: 2 })} icon={Heading2} label="H2" />
placeholder={placeholder} <ToolbarButton onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} isActive={editor.isActive('heading', { level: 3 })} icon={Heading3} label="H3" />
aria-label={t('editor.writeLabel')} <div className="w-px h-5 bg-secondary-200 mx-1" />
className={clsx( <ToolbarButton onClick={() => editor.chain().focus().toggleBulletList().run()} isActive={editor.isActive('bulletList')} icon={List} label="Bullet List" />
'w-full resize-y border-0 focus:outline-none focus:ring-0 p-4 text-sm font-mono text-secondary-900 bg-white', <ToolbarButton onClick={() => editor.chain().focus().toggleOrderedList().run()} isActive={editor.isActive('orderedList')} icon={ListOrdered} label="Ordered List" />
mode === 'split' && 'border-b md:border-b-0 md:border-r border-secondary-200', <ToolbarButton onClick={() => editor.chain().focus().toggleBlockquote().run()} isActive={editor.isActive('blockquote')} icon={Quote} label="Quote" />
minHeight <ToolbarButton onClick={() => editor.chain().focus().toggleCodeBlock().run()} isActive={editor.isActive('codeBlock')} icon={Code} label="Code Block" />
)} <div className="w-px h-5 bg-secondary-200 mx-1" />
/> <ToolbarButton onClick={setLink} isActive={editor.isActive('link')} icon={LinkIcon} label="Link" />
)} <ToolbarButton onClick={addImage} icon={ImageIcon} label="Image" />
{(mode === 'preview' || mode === 'split') && ( <div className="w-px h-5 bg-secondary-200 mx-1" />
<div className={clsx('bg-secondary-50 border-0 overflow-y-auto', minHeight)}>{preview}</div> <ToolbarButton onClick={() => editor.chain().focus().setTextAlign('left').run()} isActive={editor.isActive({ textAlign: 'left' })} icon={AlignLeft} label="Left" />
)} <ToolbarButton onClick={() => editor.chain().focus().setTextAlign('center').run()} isActive={editor.isActive({ textAlign: 'center' })} icon={AlignCenter} label="Center" />
</div> <ToolbarButton onClick={() => editor.chain().focus().setTextAlign('right').run()} isActive={editor.isActive({ textAlign: 'right' })} icon={AlignRight} label="Right" />
</div>
);
}
// Convert HTML to simple Markdown for backend storage
function htmlToMarkdown(html: string): string {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
function convertNode(node: Node): string {
if (node.nodeType === Node.TEXT_NODE) return node.textContent || '';
if (node.nodeType !== Node.ELEMENT_NODE) return '';
const el = node as HTMLElement;
const tag = el.tagName.toLowerCase();
const content = Array.from(el.childNodes).map(convertNode).join('');
switch (tag) {
case 'h1': return `\n# ${content}\n\n`;
case 'h2': return `\n## ${content}\n\n`;
case 'h3': return `\n### ${content}\n\n`;
case 'strong': case 'b': return `**${content}**`;
case 'em': case 'i': return `*${content}*`;
case 'u': return content;
case 's': case 'del': return `~~${content}~~`;
case 'ul': return Array.from(el.children).map(li => `- ${convertNode(li).trim()}`).join('\n') + '\n\n';
case 'ol': return Array.from(el.children).map((li, i) => `${i + 1}. ${convertNode(li).trim()}`).join('\n') + '\n\n';
case 'li': return content;
case 'blockquote': return `> ${content.trim()}\n\n`;
case 'pre': return `\n\`\`\`\n${el.textContent}\n\`\`\`\n\n`;
case 'code': return `\`${content}\``;
case 'a': return `[${content}](${el.getAttribute('href') || ''})`;
case 'img': return `![${el.getAttribute('alt') || ''}](${el.getAttribute('src') || ''})`;
case 'br': return '\n';
case 'p': return `${content}\n\n`;
default: return content;
}
}
return convertNode(tempDiv).trim();
}
// Convert simple Markdown to HTML for editor initialization
function markdownToHtml(md: string): string {
if (!md) return '';
// Basic markdown to HTML conversion
let html = md
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/~~(.+?)~~/g, '<s>$1</s>')
.replace(/`(.+?)`/g, '<code>$1</code>')
.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" />')
.replace(/^\- (.+)$/gm, '<li>$1</li>')
.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
// Wrap loose text in paragraphs
html = html.split(/\n\n+/).map(block => {
if (block.match(/^<(h[1-3]|ul|ol|blockquote|pre)/)) return block;
if (block.trim()) return `<p>${block.replace(/\n/g, '<br />')}</p>`;
return '';
}).join('\n');
return html;
}
export function WikiEditor({ value, onChange, placeholder, minHeight = 'min-h-72' }: WikiEditorProps) {
const editor = useEditor({
extensions: [
StarterKit,
Underline,
Link.configure({ openOnClick: false }),
Image,
Placeholder.configure({ placeholder: placeholder || 'Schreibe hier...' }),
TextAlign.configure({ types: ['heading', 'paragraph'] }),
TextStyle,
Color,
],
content: markdownToHtml(value),
onUpdate: ({ editor }) => {
const html = editor.getHTML();
const md = htmlToMarkdown(html);
onChange(md);
},
editorProps: {
attributes: {
class: clsx(
'prose prose-sm max-w-none p-4 focus:outline-none',
'min-h-72 text-secondary-900',
),
},
},
});
// Update content when value changes externally
useEffect(() => {
if (editor && value !== undefined) {
const currentMd = htmlToMarkdown(editor.getHTML());
if (currentMd !== value) {
editor.commands.setContent(markdownToHtml(value), { emitUpdate: false });
}
}
}, [value, editor]);
return (
<div className="border border-secondary-300 rounded-md overflow-hidden">
<Toolbar editor={editor} />
<EditorContent editor={editor} className={clsx('bg-white overflow-y-auto', minHeight)} />
</div> </div>
); );
} }
+52 -6
View File
@@ -81,6 +81,7 @@ export function WikiPage() {
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [restoring, setRestoring] = useState(false); const [restoring, setRestoring] = useState(false);
const [browserRefreshKey, setBrowserRefreshKey] = useState(0); const [browserRefreshKey, setBrowserRefreshKey] = useState(0);
const [wikiMode, setWikiMode] = useState<'view' | 'edit'>('view');
const loadCategories = useCallback(async () => { const loadCategories = useCallback(async () => {
try { try {
@@ -112,6 +113,7 @@ export function WikiPage() {
const handleSelectArticle = useCallback( const handleSelectArticle = useCallback(
(a: WikiArticle) => { (a: WikiArticle) => {
void loadArticle(a.id); void loadArticle(a.id);
setWikiMode('view');
}, },
[loadArticle] [loadArticle]
); );
@@ -172,6 +174,30 @@ export function WikiPage() {
} }
}, [form, editingId, t, toast]); }, [form, editingId, t, toast]);
// Inline save from View/Edit mode toggle
const handleInlineSave = useCallback(async () => {
if (!article) return;
setSaving(true);
try {
const updated = await updateWikiArticle(article.id, {
title: article.title,
content: article.content,
summary: article.summary,
category_id: article.category_id,
tags: article.tags,
status: article.status,
});
setArticle(updated);
toast.success(t('knowledge.editor.updated'));
setWikiMode('view');
setBrowserRefreshKey((k) => k + 1);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('knowledge.editor.saveError'));
} finally {
setSaving(false);
}
}, [article, t, toast]);
const handleDelete = useCallback(async () => { const handleDelete = useCallback(async () => {
if (!deleteTarget) return; if (!deleteTarget) return;
setDeleting(true); setDeleting(true);
@@ -255,12 +281,22 @@ export function WikiPage() {
actions={ actions={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{statusBadge} {statusBadge}
<Button
variant="ghost"
size="sm"
onClick={() => setWikiMode(wikiMode === 'view' ? 'edit' : 'view')}
icon={wikiMode === 'view' ? <Pencil className="h-4 w-4" aria-hidden="true" /> : <History className="h-4 w-4" aria-hidden="true" />}
>
{wikiMode === 'view' ? t('common.edit') : t('common.preview', 'Vorschau')}
</Button>
{wikiMode === 'edit' && (
<Button variant="ghost" size="sm" onClick={() => void handleInlineSave()} isLoading={saving}>
{t('common.save')}
</Button>
)}
<Button variant="ghost" size="sm" onClick={() => void loadVersions()} icon={<History className="h-4 w-4" aria-hidden="true" />}> <Button variant="ghost" size="sm" onClick={() => void loadVersions()} icon={<History className="h-4 w-4" aria-hidden="true" />}>
{t('knowledge.versions.title')} {t('knowledge.versions.title')}
</Button> </Button>
<Button variant="ghost" size="sm" onClick={openEdit} icon={<Pencil className="h-4 w-4" aria-hidden="true" />}>
{t('common.edit')}
</Button>
<Button variant="danger" size="sm" onClick={() => setDeleteTarget(article)} icon={<Trash2 className="h-4 w-4" aria-hidden="true" />}> <Button variant="danger" size="sm" onClick={() => setDeleteTarget(article)} icon={<Trash2 className="h-4 w-4" aria-hidden="true" />}>
{t('common.delete')} {t('common.delete')}
</Button> </Button>
@@ -308,9 +344,19 @@ export function WikiPage() {
</div> </div>
)} )}
<div className="prose prose-sm max-w-none"> {wikiMode === 'edit' ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>{article.content || t('wiki.detail.emptyContent')}</ReactMarkdown> <div>
</div> <WikiEditor
value={article.content}
onChange={(value) => setArticle({ ...article, content: value })}
placeholder={t('wiki.editor.contentPlaceholder')}
/>
</div>
) : (
<div className="prose prose-sm max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{article.content || t('wiki.detail.emptyContent')}</ReactMarkdown>
</div>
)}
</Card> </Card>
) : ( ) : (
<EmptyState <EmptyState