feat: improve rapportage composer UX with TipTap editor
UI improvements based on UX review: - Replace textarea with TipTap rich text editor - Add mic button in editor toolbar (collapsible recorder) - Remove duplicate type selection (keep only quick action buttons) - Remove redundant "Nieuwe rapportage:" label - Add streaming highlight on editor border during recording - Extend RichTextEditor with toolbarExtra, isStreaming props 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -52,8 +52,6 @@ export function QuickActions({
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-wrap items-center gap-2', className)}>
|
||||
<span className="text-sm text-slate-500 mr-1">Nieuwe rapportage:</span>
|
||||
|
||||
{QUICK_ACTIONS.map((action) => {
|
||||
const Icon = action.icon
|
||||
const isActive = selectedType === action.type
|
||||
|
||||
@@ -144,6 +144,8 @@ export function RapportageWorkspaceV2({
|
||||
<ReportComposer
|
||||
patientId={patientId}
|
||||
patientName={patientName}
|
||||
selectedType={selectedType}
|
||||
onTypeChange={handleTypeSelect}
|
||||
selectedReport={selectedReport}
|
||||
onReportCreated={handleReportCreated}
|
||||
initialContent={duplicateContent}
|
||||
|
||||
@@ -142,6 +142,8 @@ export function RapportageWorkspace({
|
||||
<ReportComposer
|
||||
patientId={patientId}
|
||||
patientName={patientName}
|
||||
selectedType={selectedType}
|
||||
onTypeChange={handleTypeSelect}
|
||||
selectedReport={selectedReport}
|
||||
onReportCreated={handleReportCreated}
|
||||
initialContent={duplicateContent}
|
||||
|
||||
@@ -1,19 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useMemo, useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Mic, Sparkles } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import type { ClassificationResult, Report } from '@/lib/types/report';
|
||||
import type { ClassificationResult, Report, ReportType } from '@/lib/types/report';
|
||||
import { createReport } from '../actions';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Editor } from '@/components/rich-text-editor';
|
||||
|
||||
// Dynamic imports
|
||||
const RichTextEditor = dynamic(
|
||||
() => import('@/components/rich-text-editor').then((m) => m.RichTextEditor),
|
||||
{ ssr: false, loading: () => <EditorSkeleton /> }
|
||||
);
|
||||
|
||||
const SpeechRecorderStreaming = dynamic(
|
||||
() => import('@/components/speech-recorder-streaming').then((m) => m.SpeechRecorderStreaming),
|
||||
{ ssr: false, loading: () => <RecorderSkeleton /> }
|
||||
);
|
||||
|
||||
function EditorSkeleton() {
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 animate-pulse">
|
||||
<div className="h-10 bg-slate-100 border-b border-slate-200" />
|
||||
<div className="h-40 bg-white" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecorderSkeleton() {
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-4 animate-pulse">
|
||||
@@ -23,9 +40,17 @@ function RecorderSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ReportComposerProps {
|
||||
patientId: string;
|
||||
patientName: string;
|
||||
/** Het geselecteerde report type (van parent) */
|
||||
selectedType: ReportType;
|
||||
/** Callback om type te wijzigen */
|
||||
onTypeChange?: (type: ReportType) => void;
|
||||
selectedReport?: Report | null;
|
||||
onReportCreated?: (report: Report) => void;
|
||||
/** Initial content voor de editor (bijv. van duplicate) */
|
||||
@@ -34,64 +59,52 @@ interface ReportComposerProps {
|
||||
onInitialContentConsumed?: () => void;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Component
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ReportComposer({
|
||||
patientId,
|
||||
patientName,
|
||||
selectedType,
|
||||
onTypeChange,
|
||||
selectedReport,
|
||||
onReportCreated,
|
||||
initialContent,
|
||||
onInitialContentConsumed,
|
||||
}: ReportComposerProps) {
|
||||
const router = useRouter();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [editorRef, setEditorRef] = useState<Editor | null>(null);
|
||||
const [content, setContent] = useState('');
|
||||
const [classification, setClassification] = useState<ClassificationResult | null>(null);
|
||||
const [selectedType, setSelectedType] = useState<'behandeladvies' | 'vrije_notitie'>('vrije_notitie');
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastAutosave, setLastAutosave] = useState<Date | null>(null);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [interimText, setInterimText] = useState('');
|
||||
const [showRecorder, setShowRecorder] = useState(false);
|
||||
const draftStorageKey = useMemo(() => `rapportage-draft-${patientId}`, [patientId]);
|
||||
|
||||
// Calculate plain text length from HTML content
|
||||
const textContent = useMemo(() => {
|
||||
if (typeof document === 'undefined') return '';
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = content;
|
||||
return div.textContent || div.innerText || '';
|
||||
}, [content]);
|
||||
|
||||
const characterCount = textContent.length;
|
||||
const contentInvalid = characterCount < 20 || characterCount > 5000;
|
||||
|
||||
// Handle initialContent (e.g., from duplicate)
|
||||
useEffect(() => {
|
||||
if (initialContent) {
|
||||
setContent(initialContent);
|
||||
onInitialContentConsumed?.();
|
||||
// Focus en scroll naar einde
|
||||
setTimeout(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
textareaRef.current.scrollTop = textareaRef.current.scrollHeight;
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, [initialContent, onInitialContentConsumed]);
|
||||
|
||||
// Cursor naar einde verplaatsen bij start opname
|
||||
const handleRecordingStart = useCallback(() => {
|
||||
setIsStreaming(true);
|
||||
if (textareaRef.current) {
|
||||
const textarea = textareaRef.current;
|
||||
textarea.focus();
|
||||
// Verplaats cursor naar het einde
|
||||
const length = textarea.value.length;
|
||||
textarea.setSelectionRange(length, length);
|
||||
// Scroll naar beneden
|
||||
textarea.scrollTop = textarea.scrollHeight;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRecordingStop = useCallback(() => {
|
||||
setIsStreaming(false);
|
||||
setInterimText('');
|
||||
}, []);
|
||||
|
||||
const characterCount = content.length;
|
||||
const contentInvalid = characterCount < 20 || characterCount > 5000;
|
||||
|
||||
// Load draft from localStorage
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const stored = window.localStorage.getItem(draftStorageKey);
|
||||
@@ -99,14 +112,14 @@ export function ReportComposer({
|
||||
try {
|
||||
const draft = JSON.parse(stored) as {
|
||||
content?: string;
|
||||
type?: 'behandeladvies' | 'vrije_notitie';
|
||||
type?: ReportType;
|
||||
updatedAt?: string;
|
||||
};
|
||||
if (draft.content) {
|
||||
setContent(draft.content);
|
||||
}
|
||||
if (draft.type) {
|
||||
setSelectedType(draft.type);
|
||||
if (draft.type && onTypeChange) {
|
||||
onTypeChange(draft.type);
|
||||
}
|
||||
if (draft.updatedAt) {
|
||||
setLastAutosave(new Date(draft.updatedAt));
|
||||
@@ -116,8 +129,9 @@ export function ReportComposer({
|
||||
window.localStorage.removeItem(draftStorageKey);
|
||||
}
|
||||
}
|
||||
}, [draftStorageKey]);
|
||||
}, [draftStorageKey, onTypeChange]);
|
||||
|
||||
// Autosave draft
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (!content && selectedType === 'vrije_notitie') {
|
||||
@@ -139,6 +153,30 @@ export function ReportComposer({
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [content, selectedType, draftStorageKey]);
|
||||
|
||||
// Recording handlers
|
||||
const handleRecordingStart = useCallback(() => {
|
||||
setIsStreaming(true);
|
||||
if (editorRef) {
|
||||
editorRef.chain().focus().run();
|
||||
}
|
||||
}, [editorRef]);
|
||||
|
||||
const handleRecordingStop = useCallback(() => {
|
||||
setIsStreaming(false);
|
||||
}, []);
|
||||
|
||||
const handleTranscript = useCallback((text: string) => {
|
||||
setContent((prev) => {
|
||||
// If editor has content, append with space
|
||||
const plainPrev = prev.replace(/<[^>]*>/g, '').trim();
|
||||
if (plainPrev) {
|
||||
return `${prev}<p>${text}</p>`;
|
||||
}
|
||||
return `<p>${text}</p>`;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Reference snippet for context
|
||||
const referenceSnippet = useMemo(() => {
|
||||
if (!selectedReport) return null;
|
||||
const createdAt = selectedReport.created_at ? new Date(selectedReport.created_at) : null;
|
||||
@@ -161,10 +199,8 @@ export function ReportComposer({
|
||||
try {
|
||||
const response = await fetch('/api/reports/classify', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ content }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: textContent }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -173,11 +209,10 @@ export function ReportComposer({
|
||||
|
||||
const result: ClassificationResult = await response.json();
|
||||
setClassification(result);
|
||||
setSelectedType(result.type);
|
||||
onTypeChange?.(result.type);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'AI-analyse mislukt';
|
||||
setClassification(null);
|
||||
setSelectedType('vrije_notitie');
|
||||
setError(message);
|
||||
toast({ variant: 'destructive', title: 'AI-analyse mislukt', description: message });
|
||||
} finally {
|
||||
@@ -191,7 +226,7 @@ export function ReportComposer({
|
||||
try {
|
||||
const created = await createReport(patientId, {
|
||||
type: selectedType,
|
||||
content,
|
||||
content: textContent, // Save plain text for now
|
||||
ai_confidence: classification?.confidence,
|
||||
ai_reasoning: classification?.reasoning,
|
||||
});
|
||||
@@ -202,7 +237,6 @@ export function ReportComposer({
|
||||
onReportCreated?.(created);
|
||||
setContent('');
|
||||
setClassification(null);
|
||||
setSelectedType('vrije_notitie');
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.removeItem(draftStorageKey);
|
||||
}
|
||||
@@ -219,19 +253,33 @@ export function ReportComposer({
|
||||
|
||||
const insertReference = () => {
|
||||
if (!selectedReport || !referenceSnippet) return;
|
||||
const prefix = content ? `${content.trim()}\n\n` : '';
|
||||
const block = `> ${referenceSnippet.preview}\n(${referenceSnippet.type} • ${referenceSnippet.meta})`;
|
||||
setContent(`${prefix}${block}\n\n`);
|
||||
const block = `<blockquote>${referenceSnippet.preview}</blockquote><p><em>(${referenceSnippet.type} • ${referenceSnippet.meta})</em></p>`;
|
||||
setContent((prev) => (prev ? `${prev}${block}` : block));
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
id="rapportage-composer"
|
||||
tabIndex={-1}
|
||||
className="focus:outline-none"
|
||||
// Mic button for toolbar
|
||||
const MicToolbarButton = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRecorder((prev) => !prev)}
|
||||
className={cn(
|
||||
'inline-flex h-7 items-center gap-1 px-2 rounded-md text-xs font-medium transition-colors',
|
||||
showRecorder || isStreaming
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: 'text-slate-600 hover:bg-white hover:text-slate-900'
|
||||
)}
|
||||
title="Spraakopname"
|
||||
>
|
||||
<Mic className={cn('h-4 w-4', isStreaming && 'text-emerald-600 animate-pulse')} />
|
||||
{isStreaming && <span className="text-emerald-600">●</span>}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<section id="rapportage-composer" tabIndex={-1} className="focus:outline-none space-y-4">
|
||||
{/* Reference snippet */}
|
||||
{referenceSnippet && (
|
||||
<div className="mb-4 rounded-xl border border-slate-100 bg-slate-50 p-4">
|
||||
<div className="rounded-xl border border-slate-100 bg-slate-50 p-4">
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-slate-500">
|
||||
<span className="font-semibold text-slate-700">Geselecteerde rapportage</span>
|
||||
<span>{referenceSnippet.meta}</span>
|
||||
@@ -239,88 +287,58 @@ export function ReportComposer({
|
||||
<p className="mt-2 text-sm text-slate-700">{referenceSnippet.preview}</p>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<span className="text-xs uppercase tracking-wide text-slate-400">{referenceSnippet.type}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={insertReference}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<Button type="button" variant="outline" size="sm" onClick={insertReference} disabled={isSaving}>
|
||||
Voeg referentie toe
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="Beschrijf wat je wilt vastleggen..."
|
||||
className={cn(
|
||||
'w-full min-h-[200px] rounded-xl border bg-white p-4 text-sm text-slate-900 shadow-inner focus:outline-none transition-all duration-200',
|
||||
isStreaming
|
||||
? 'border-emerald-500 border-2 shadow-emerald-500/20 shadow-md'
|
||||
: 'border-slate-200 focus:border-teal-500'
|
||||
)}
|
||||
/>
|
||||
{/* Interim tekst preview tijdens streaming */}
|
||||
{interimText && (
|
||||
<div className="mt-1 text-sm text-slate-500 italic px-1">
|
||||
{interimText}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-xs text-slate-500 mt-1">
|
||||
<span>{characterCount} / 5000 karakters</span>
|
||||
{contentInvalid && <span>Minimaal 20 karakters</span>}
|
||||
</div>
|
||||
</div>
|
||||
{/* Editor */}
|
||||
<RichTextEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
placeholder="Begin met typen of gebruik spraakopname..."
|
||||
toolbarExtra={MicToolbarButton}
|
||||
onEditorReady={setEditorRef}
|
||||
isStreaming={isStreaming}
|
||||
minHeight="180px"
|
||||
/>
|
||||
|
||||
<SpeechRecorderStreaming
|
||||
disabled={isSaving || isAnalyzing}
|
||||
onTranscript={(text) =>
|
||||
setContent((prev) => (prev ? `${prev} ${text}` : text))
|
||||
}
|
||||
onInterimTranscript={setInterimText}
|
||||
onRecordingStart={handleRecordingStart}
|
||||
onRecordingStop={handleRecordingStop}
|
||||
telemetryContext={{
|
||||
context: 'report_composer',
|
||||
patientId,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="rounded-xl border border-slate-200 p-4 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">Rapportagetype</p>
|
||||
<p className="text-xs text-slate-500">Stel handmatig in of gebruik het AI-voorstel.</p>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500">
|
||||
{classification
|
||||
? `AI: ${classification.type} (${Math.round(classification.confidence * 100)}%)`
|
||||
: 'Nog geen AI-analyse'}
|
||||
</span>
|
||||
</div>
|
||||
{classification?.reasoning && (
|
||||
<p className="mt-2 text-xs text-slate-500">{classification.reasoning}</p>
|
||||
)}
|
||||
<select
|
||||
value={selectedType}
|
||||
onChange={(e) => setSelectedType(e.target.value as 'behandeladvies' | 'vrije_notitie')}
|
||||
className="mt-3 w-full rounded-md border border-slate-200 bg-white p-2 text-sm"
|
||||
>
|
||||
<option value="behandeladvies">Behandeladvies</option>
|
||||
<option value="vrije_notitie">Vrije notitie</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
{/* Character count */}
|
||||
<div className="flex justify-between text-xs text-slate-500 -mt-2">
|
||||
<span>{characterCount} / 5000 karakters</span>
|
||||
{characterCount > 0 && characterCount < 20 && <span className="text-amber-600">Minimaal 20 karakters</span>}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center justify-between gap-3 border-t border-slate-100 pt-4">
|
||||
{/* Speech recorder (collapsible) */}
|
||||
{showRecorder && (
|
||||
<div className="animate-in slide-in-from-top-2 duration-200">
|
||||
<SpeechRecorderStreaming
|
||||
disabled={isSaving || isAnalyzing}
|
||||
onTranscript={handleTranscript}
|
||||
onRecordingStart={handleRecordingStart}
|
||||
onRecordingStop={handleRecordingStop}
|
||||
telemetryContext={{ context: 'report_composer', patientId }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Classification result (inline) */}
|
||||
{classification && (
|
||||
<div className="flex items-center gap-2 text-sm text-slate-600 bg-slate-50 rounded-lg px-3 py-2">
|
||||
<Sparkles className="h-4 w-4 text-amber-500" />
|
||||
<span>
|
||||
AI suggereert: <strong>{classification.type === 'behandeladvies' ? 'Behandeladvies' : 'Vrije notitie'}</strong>
|
||||
<span className="text-slate-400 ml-1">({Math.round(classification.confidence * 100)}% zeker)</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-100 pt-4">
|
||||
<p className="text-xs text-slate-500">
|
||||
{isSaving
|
||||
? 'Opslaan bezig…'
|
||||
@@ -338,13 +356,10 @@ export function ReportComposer({
|
||||
onClick={analyzeWithAI}
|
||||
disabled={contentInvalid || isAnalyzing || isSaving}
|
||||
>
|
||||
<Sparkles className="h-4 w-4 mr-1" />
|
||||
{isAnalyzing ? 'Analyseren…' : 'Analyseer met AI'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={saveReport}
|
||||
disabled={contentInvalid || isSaving}
|
||||
>
|
||||
<Button type="button" onClick={saveReport} disabled={contentInvalid || isSaving}>
|
||||
{isSaving ? 'Opslaan…' : 'Opslaan'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { EditorContent, useEditor } from '@tiptap/react';
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import { EditorContent, useEditor, type Editor } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import { Bold, Italic, List, ListOrdered, Quote } from 'lucide-react';
|
||||
@@ -11,9 +11,25 @@ interface RichTextEditorProps {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
/** Extra content voor in de toolbar (bijv. mic button) */
|
||||
toolbarExtra?: ReactNode;
|
||||
/** Callback met editor instance voor externe controle */
|
||||
onEditorReady?: (editor: Editor) => void;
|
||||
/** Highlight border tijdens streaming */
|
||||
isStreaming?: boolean;
|
||||
/** Min height voor editor */
|
||||
minHeight?: string;
|
||||
}
|
||||
|
||||
export function RichTextEditor({ value, onChange, placeholder }: RichTextEditorProps) {
|
||||
export function RichTextEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
toolbarExtra,
|
||||
onEditorReady,
|
||||
isStreaming = false,
|
||||
minHeight = '160px',
|
||||
}: RichTextEditorProps) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
@@ -26,12 +42,12 @@ export function RichTextEditor({ value, onChange, placeholder }: RichTextEditorP
|
||||
content: value || '',
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
'prose prose-sm max-w-none focus:outline-none min-h-[160px] px-3 py-2 text-slate-800 bg-white',
|
||||
class: `prose prose-sm max-w-none focus:outline-none px-3 py-2 text-slate-800 bg-white`,
|
||||
style: `min-height: ${minHeight}`,
|
||||
},
|
||||
},
|
||||
onUpdate({ editor }) {
|
||||
onChange?.(editor.getHTML());
|
||||
onUpdate({ editor: e }) {
|
||||
onChange?.(e.getHTML());
|
||||
},
|
||||
immediatelyRender: false,
|
||||
});
|
||||
@@ -44,6 +60,13 @@ export function RichTextEditor({ value, onChange, placeholder }: RichTextEditorP
|
||||
}
|
||||
}, [value, editor]);
|
||||
|
||||
// Notify parent when editor is ready
|
||||
useEffect(() => {
|
||||
if (editor && onEditorReady) {
|
||||
onEditorReady(editor);
|
||||
}
|
||||
}, [editor, onEditorReady]);
|
||||
|
||||
if (!editor) {
|
||||
return <div className="rounded-lg border border-slate-200 h-32 animate-pulse bg-slate-50" />;
|
||||
}
|
||||
@@ -54,60 +77,77 @@ export function RichTextEditor({ value, onChange, placeholder }: RichTextEditorP
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 overflow-hidden">
|
||||
<div className="flex items-center gap-1 border-b border-slate-200 bg-slate-50 px-2 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(() => editor.chain().focus().toggleBold().run())}
|
||||
className={cn(
|
||||
'inline-flex h-7 w-7 items-center justify-center rounded-md text-xs text-slate-600 hover:bg-white',
|
||||
editor.isActive('bold') && 'bg-white text-slate-900'
|
||||
)}
|
||||
>
|
||||
<Bold className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(() => editor.chain().focus().toggleItalic().run())}
|
||||
className={cn(
|
||||
'inline-flex h-7 w-7 items-center justify-center rounded-md text-xs text-slate-600 hover:bg-white',
|
||||
editor.isActive('italic') && 'bg-white text-slate-900'
|
||||
)}
|
||||
>
|
||||
<Italic className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(() => editor.chain().focus().toggleBulletList().run())}
|
||||
className={cn(
|
||||
'inline-flex h-7 w-7 items-center justify-center rounded-md text-xs text-slate-600 hover:bg-white',
|
||||
editor.isActive('bulletList') && 'bg-white text-slate-900'
|
||||
)}
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(() => editor.chain().focus().toggleOrderedList().run())}
|
||||
className={cn(
|
||||
'inline-flex h-7 w-7 items-center justify-center rounded-md text-xs text-slate-600 hover:bg-white',
|
||||
editor.isActive('orderedList') && 'bg-white text-slate-900'
|
||||
)}
|
||||
>
|
||||
<ListOrdered className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(() => editor.chain().focus().toggleBlockquote().run())}
|
||||
className={cn(
|
||||
'inline-flex h-7 w-7 items-center justify-center rounded-md text-xs text-slate-600 hover:bg-white',
|
||||
editor.isActive('blockquote') && 'bg-white text-slate-900'
|
||||
)}
|
||||
>
|
||||
<Quote className="h-4 w-4" />
|
||||
</button>
|
||||
<div className={cn(
|
||||
'rounded-lg border overflow-hidden transition-all duration-200',
|
||||
isStreaming
|
||||
? 'border-emerald-500 border-2 shadow-emerald-500/20 shadow-md'
|
||||
: 'border-slate-200'
|
||||
)}>
|
||||
<div className="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-2 py-1.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(() => editor.chain().focus().toggleBold().run())}
|
||||
className={cn(
|
||||
'inline-flex h-7 w-7 items-center justify-center rounded-md text-xs text-slate-600 hover:bg-white',
|
||||
editor.isActive('bold') && 'bg-white text-slate-900'
|
||||
)}
|
||||
>
|
||||
<Bold className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(() => editor.chain().focus().toggleItalic().run())}
|
||||
className={cn(
|
||||
'inline-flex h-7 w-7 items-center justify-center rounded-md text-xs text-slate-600 hover:bg-white',
|
||||
editor.isActive('italic') && 'bg-white text-slate-900'
|
||||
)}
|
||||
>
|
||||
<Italic className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(() => editor.chain().focus().toggleBulletList().run())}
|
||||
className={cn(
|
||||
'inline-flex h-7 w-7 items-center justify-center rounded-md text-xs text-slate-600 hover:bg-white',
|
||||
editor.isActive('bulletList') && 'bg-white text-slate-900'
|
||||
)}
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(() => editor.chain().focus().toggleOrderedList().run())}
|
||||
className={cn(
|
||||
'inline-flex h-7 w-7 items-center justify-center rounded-md text-xs text-slate-600 hover:bg-white',
|
||||
editor.isActive('orderedList') && 'bg-white text-slate-900'
|
||||
)}
|
||||
>
|
||||
<ListOrdered className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(() => editor.chain().focus().toggleBlockquote().run())}
|
||||
className={cn(
|
||||
'inline-flex h-7 w-7 items-center justify-center rounded-md text-xs text-slate-600 hover:bg-white',
|
||||
editor.isActive('blockquote') && 'bg-white text-slate-900'
|
||||
)}
|
||||
>
|
||||
<Quote className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Extra toolbar content (e.g., mic button) */}
|
||||
{toolbarExtra && (
|
||||
<div className="flex items-center gap-1">
|
||||
{toolbarExtra}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Export Editor type for external use
|
||||
export type { Editor } from '@tiptap/react';
|
||||
|
||||
BIN
docs/troubleshooting/screenprint-nieuwe-rapportage-ux.png
Normal file
BIN
docs/troubleshooting/screenprint-nieuwe-rapportage-ux.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
Reference in New Issue
Block a user