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:
@@ -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 ` || ''})`;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user