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
* the live preview pane. Content is stored as Markdown in the backend.
* Features: Floating toolbar, Markdown export for backend storage,
* 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 ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useEditor, EditorContent, Editor } from '@tiptap/react';
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 { 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 {
value: string;
@@ -19,61 +30,187 @@ export interface WikiEditorProps {
minHeight?: string;
}
type EditorMode = 'write' | 'preview' | 'split';
export function WikiEditor({ value, onChange, placeholder, minHeight = 'min-h-72' }: WikiEditorProps) {
const { t } = useTranslation();
const [mode, setMode] = useState<EditorMode>('split');
const preview = useMemo(
() => (
<div className="prose prose-sm max-w-none px-4 py-3 text-secondary-800">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{value || t('wiki.editor.emptyPreview')}</ReactMarkdown>
</div>
),
[value, t]
);
const modeButton = (m: EditorMode, label: string, Icon: React.ComponentType<{ className?: string }>) => (
function ToolbarButton({
onClick, isActive, disabled, icon: Icon, label,
}: {
onClick: () => void;
isActive?: boolean;
disabled?: boolean;
icon: React.ComponentType<{ className?: string }>;
label: string;
}) {
return (
<button
type="button"
onClick={() => setMode(m)}
onClick={onClick}
disabled={disabled}
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',
mode === m ? 'bg-primary-600 text-white' : 'text-secondary-600 hover:bg-secondary-100'
'p-1.5 rounded-md transition-colors min-h-touch min-w-touch',
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" />
{label}
<Icon className="h-4 w-4" />
</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 (
<div className="border border-secondary-300 rounded-md overflow-hidden">
<div className="flex items-center gap-1 border-b border-secondary-200 bg-secondary-50 px-2 py-1.5">
{modeButton('write', t('editor.write'), PencilLine)}
{modeButton('preview', t('editor.preview'), Eye)}
{modeButton('split', t('editor.split'), Columns2)}
</div>
<div className={clsx('grid', mode === 'split' ? 'grid-cols-1 md:grid-cols-2' : 'grid-cols-1')}>
{(mode === 'write' || mode === 'split') && (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
aria-label={t('editor.writeLabel')}
className={clsx(
'w-full resize-y border-0 focus:outline-none focus:ring-0 p-4 text-sm font-mono text-secondary-900 bg-white',
mode === 'split' && 'border-b md:border-b-0 md:border-r border-secondary-200',
minHeight
)}
/>
)}
{(mode === 'preview' || mode === 'split') && (
<div className={clsx('bg-secondary-50 border-0 overflow-y-auto', minHeight)}>{preview}</div>
)}
</div>
<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">
<ToolbarButton onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} icon={Undo} label={t('common.undo', 'Rückgängig')} />
<ToolbarButton onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()} icon={Redo} label={t('common.redo', 'Wiederholen')} />
<div className="w-px h-5 bg-secondary-200 mx-1" />
<ToolbarButton onClick={() => editor.chain().focus().toggleBold().run()} isActive={editor.isActive('bold')} icon={Bold} label="Bold" />
<ToolbarButton onClick={() => editor.chain().focus().toggleItalic().run()} isActive={editor.isActive('italic')} icon={Italic} label="Italic" />
<ToolbarButton onClick={() => editor.chain().focus().toggleUnderline().run()} isActive={editor.isActive('underline')} icon={UnderlineIcon} label="Underline" />
<ToolbarButton onClick={() => editor.chain().focus().toggleStrike().run()} isActive={editor.isActive('strike')} icon={Strikethrough} label="Strike" />
<div className="w-px h-5 bg-secondary-200 mx-1" />
<ToolbarButton onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} isActive={editor.isActive('heading', { level: 1 })} icon={Heading1} label="H1" />
<ToolbarButton onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} isActive={editor.isActive('heading', { level: 2 })} icon={Heading2} label="H2" />
<ToolbarButton onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} isActive={editor.isActive('heading', { level: 3 })} icon={Heading3} label="H3" />
<div className="w-px h-5 bg-secondary-200 mx-1" />
<ToolbarButton onClick={() => editor.chain().focus().toggleBulletList().run()} isActive={editor.isActive('bulletList')} icon={List} label="Bullet List" />
<ToolbarButton onClick={() => editor.chain().focus().toggleOrderedList().run()} isActive={editor.isActive('orderedList')} icon={ListOrdered} label="Ordered List" />
<ToolbarButton onClick={() => editor.chain().focus().toggleBlockquote().run()} isActive={editor.isActive('blockquote')} icon={Quote} label="Quote" />
<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" />
<div className="w-px h-5 bg-secondary-200 mx-1" />
<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" />
<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>
);
}
+52 -6
View File
@@ -81,6 +81,7 @@ export function WikiPage() {
const [deleting, setDeleting] = useState(false);
const [restoring, setRestoring] = useState(false);
const [browserRefreshKey, setBrowserRefreshKey] = useState(0);
const [wikiMode, setWikiMode] = useState<'view' | 'edit'>('view');
const loadCategories = useCallback(async () => {
try {
@@ -112,6 +113,7 @@ export function WikiPage() {
const handleSelectArticle = useCallback(
(a: WikiArticle) => {
void loadArticle(a.id);
setWikiMode('view');
},
[loadArticle]
);
@@ -172,6 +174,30 @@ export function WikiPage() {
}
}, [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 () => {
if (!deleteTarget) return;
setDeleting(true);
@@ -255,12 +281,22 @@ export function WikiPage() {
actions={
<div className="flex items-center gap-2">
{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" />}>
{t('knowledge.versions.title')}
</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" />}>
{t('common.delete')}
</Button>
@@ -308,9 +344,19 @@ export function WikiPage() {
</div>
)}
<div className="prose prose-sm max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{article.content || t('wiki.detail.emptyContent')}</ReactMarkdown>
</div>
{wikiMode === 'edit' ? (
<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>
) : (
<EmptyState