Skip to content

Commit 2a47d97

Browse files
authored
Merge pull request #24 from wellmaintained/feat/tiptap-editor-integration
Add tiptap rich tect markdown editor
2 parents 1c7c0ca + d50f722 commit 2a47d97

File tree

13 files changed

+1607
-202
lines changed

13 files changed

+1607
-202
lines changed

components/decision-relationships-list.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,6 @@ export function DecisionRelationshipsList({
5757
description: '',
5858
cost: 'low',
5959
createdAt: new Date(),
60-
criteria: [],
61-
options: [],
6260
reversibility: 'hat',
6361
stakeholders: [],
6462
driverStakeholderId: '',

components/decision-summary.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/com
22
import { Decision } from "@/lib/domain/Decision"
33
import { Stakeholder } from "@/lib/domain/Stakeholder"
44
import { StakeholderRoleGroups } from "@/components/stakeholders/StakeholderRoleGroups"
5+
import { TipTapView } from '@/components/tiptap-view'
56

67
interface DecisionSummaryProps {
78
decision: Decision
@@ -34,7 +35,9 @@ export function DecisionSummary({
3435
<CardContent className="space-y-6">
3536
<div className="space-y-2">
3637
<h3 className="text-muted-foreground">Description</h3>
37-
<p>{decision.description}</p>
38+
<div className="prose prose-sm dark:prose-invert max-w-none">
39+
<TipTapView content={decision.description || ''} />
40+
</div>
3841
</div>
3942

4043
{!compact && (
@@ -52,8 +55,8 @@ export function DecisionSummary({
5255

5356
<div className="space-y-2">
5457
<h3 className="text-muted-foreground">Decision</h3>
55-
<div className="rounded-md bg-muted p-4">
56-
{decision.decision || "No decision recorded"}
58+
<div className="rounded-md bg-muted p-4 prose prose-sm dark:prose-invert max-w-none">
59+
<TipTapView content={decision.decision || "No decision recorded"} />
5760
</div>
5861
</div>
5962

components/editor.tsx

Lines changed: 0 additions & 67 deletions
This file was deleted.

components/tiptap-editor.tsx

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
'use client'
2+
3+
import * as React from 'react'
4+
import { useEditor, EditorContent } from '@tiptap/react'
5+
import StarterKit from '@tiptap/starter-kit'
6+
import { Markdown } from 'tiptap-markdown'
7+
import { Bold, Italic, List, ListOrdered, Code, Heading1, Heading2, Heading3 } from 'lucide-react'
8+
import { Button } from '@/components/ui/button'
9+
import { Card } from '@/components/ui/card'
10+
import { Textarea } from '@/components/ui/textarea'
11+
import {
12+
Tooltip,
13+
TooltipContent,
14+
TooltipProvider,
15+
TooltipTrigger,
16+
} from '@/components/ui/tooltip'
17+
18+
interface TipTapEditorProps {
19+
content: string
20+
onChange: (content: string) => void
21+
className?: string
22+
}
23+
24+
export function TipTapEditor({ content, onChange, className = '' }: TipTapEditorProps) {
25+
const [isFocused, setIsFocused] = React.useState(false);
26+
const [isRawMode, setIsRawMode] = React.useState(false);
27+
const [rawMarkdown, setRawMarkdown] = React.useState(content || '');
28+
29+
const editor = useEditor({
30+
extensions: [
31+
StarterKit.configure({
32+
bold: {
33+
HTMLAttributes: {
34+
class: 'font-bold'
35+
}
36+
},
37+
heading: {
38+
levels: [1, 2, 3]
39+
}
40+
}),
41+
Markdown.configure({
42+
html: false,
43+
transformPastedText: true,
44+
transformCopiedText: true
45+
})
46+
],
47+
content: rawMarkdown,
48+
onUpdate: ({ editor }) => {
49+
// Use the markdown extension to get markdown content
50+
const markdown = editor.storage.markdown.getMarkdown();
51+
setRawMarkdown(markdown);
52+
onChange(markdown);
53+
},
54+
editorProps: {
55+
attributes: {
56+
class: 'prose prose-sm dark:prose-invert focus:outline-none max-w-none p-4 min-h-[200px]'
57+
}
58+
},
59+
onFocus: () => setIsFocused(true),
60+
onBlur: () => setIsFocused(false)
61+
});
62+
63+
// Update raw markdown when content changes from outside
64+
React.useEffect(() => {
65+
if (!isRawMode) {
66+
setRawMarkdown(content || '');
67+
}
68+
}, [content, isRawMode]);
69+
70+
// Global event handler for keyboard shortcuts
71+
React.useEffect(() => {
72+
const handleKeyDown = (e: KeyboardEvent) => {
73+
if (isFocused && (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'b') {
74+
// Prevent default behavior AND stop propagation
75+
e.preventDefault();
76+
e.stopPropagation();
77+
78+
// Manually toggle bold in the editor
79+
if (editor) {
80+
editor.chain().focus().toggleBold().run();
81+
}
82+
83+
return false;
84+
}
85+
};
86+
87+
// Add to document level to catch all events
88+
document.addEventListener('keydown', handleKeyDown, true);
89+
90+
return () => {
91+
document.removeEventListener('keydown', handleKeyDown, true);
92+
};
93+
}, [isFocused, editor]);
94+
95+
// Toggle between rich text and raw markdown modes
96+
const toggleEditMode = () => {
97+
if (isRawMode && editor) {
98+
// When switching from raw to rich, set the markdown content
99+
editor.commands.clearContent();
100+
editor.commands.insertContent(rawMarkdown);
101+
}
102+
setIsRawMode(!isRawMode);
103+
};
104+
105+
// Handle raw markdown changes
106+
const handleRawMarkdownChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
107+
const newValue = e.target.value;
108+
setRawMarkdown(newValue);
109+
onChange(newValue);
110+
};
111+
112+
if (!editor && !isRawMode) {
113+
return null;
114+
}
115+
116+
const tools = [
117+
{
118+
icon: Heading1,
119+
title: 'Heading 1',
120+
action: () => editor?.chain().focus().toggleHeading({ level: 1 }).run(),
121+
isActive: () => editor?.isActive('heading', { level: 1 }) || false,
122+
showInRawMode: false,
123+
},
124+
{
125+
icon: Heading2,
126+
title: 'Heading 2',
127+
action: () => editor?.chain().focus().toggleHeading({ level: 2 }).run(),
128+
isActive: () => editor?.isActive('heading', { level: 2 }) || false,
129+
showInRawMode: false,
130+
},
131+
{
132+
icon: Heading3,
133+
title: 'Heading 3',
134+
action: () => editor?.chain().focus().toggleHeading({ level: 3 }).run(),
135+
isActive: () => editor?.isActive('heading', { level: 3 }) || false,
136+
showInRawMode: false,
137+
},
138+
{
139+
icon: Bold,
140+
title: 'Bold',
141+
action: () => editor?.chain().focus().toggleBold().run(),
142+
isActive: () => editor?.isActive('bold') || false,
143+
showInRawMode: false,
144+
},
145+
{
146+
icon: Italic,
147+
title: 'Italic',
148+
action: () => editor?.chain().focus().toggleItalic().run(),
149+
isActive: () => editor?.isActive('italic') || false,
150+
showInRawMode: false,
151+
},
152+
{
153+
icon: List,
154+
title: 'Bullet List',
155+
action: () => editor?.chain().focus().toggleBulletList().run(),
156+
isActive: () => editor?.isActive('bulletList') || false,
157+
showInRawMode: false,
158+
},
159+
{
160+
icon: ListOrdered,
161+
title: 'Numbered List',
162+
action: () => editor?.chain().focus().toggleOrderedList().run(),
163+
isActive: () => editor?.isActive('orderedList') || false,
164+
showInRawMode: false,
165+
},
166+
{
167+
icon: Code,
168+
title: 'Toggle Raw Markdown',
169+
action: toggleEditMode,
170+
isActive: () => isRawMode,
171+
showInRawMode: true,
172+
},
173+
];
174+
175+
return (
176+
<Card className={`min-h-[300px] ${className}`}>
177+
<div className="flex items-center gap-1 border-b p-2">
178+
<TooltipProvider>
179+
{tools
180+
.filter(tool => isRawMode ? tool.showInRawMode : true)
181+
.map((Tool) => (
182+
<Tooltip key={Tool.title}>
183+
<TooltipTrigger asChild>
184+
<Button
185+
variant={Tool.isActive() ? "secondary" : "ghost"}
186+
size="icon"
187+
className="h-8 w-8"
188+
onClick={Tool.action}
189+
title={Tool.title}
190+
>
191+
<Tool.icon className="h-4 w-4" />
192+
</Button>
193+
</TooltipTrigger>
194+
<TooltipContent>{Tool.title}</TooltipContent>
195+
</Tooltip>
196+
))}
197+
</TooltipProvider>
198+
</div>
199+
200+
{isRawMode ? (
201+
<Textarea
202+
value={rawMarkdown}
203+
onChange={handleRawMarkdownChange}
204+
className="min-h-[200px] resize-none border-none rounded-none focus-visible:ring-0 p-4 font-mono text-sm"
205+
placeholder="Enter markdown here..."
206+
/>
207+
) : (
208+
<div className="[&_.ProseMirror]:focus-visible:outline-none [&_.ProseMirror]:focus-visible:ring-0">
209+
<EditorContent
210+
editor={editor}
211+
className="[&_ul]:list-disc [&_ul]:ml-4 [&_ol]:list-decimal [&_ol]:ml-4 [&_h1]:text-3xl [&_h1]:font-bold [&_h1]:mb-4 [&_h2]:text-2xl [&_h2]:font-bold [&_h2]:mb-3 [&_h3]:text-xl [&_h3]:font-bold [&_h3]:mb-2"
212+
/>
213+
</div>
214+
)}
215+
</Card>
216+
)
217+
}

components/tiptap-view.tsx

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
'use client'
2+
3+
import * as React from 'react'
4+
import { useEditor, EditorContent } from '@tiptap/react'
5+
import StarterKit from '@tiptap/starter-kit'
6+
import { Markdown } from 'tiptap-markdown'
7+
8+
interface TipTapViewProps {
9+
content: string
10+
className?: string
11+
}
12+
13+
export function TipTapView({ content, className = '' }: TipTapViewProps) {
14+
const editor = useEditor({
15+
extensions: [
16+
StarterKit.configure({
17+
bold: {
18+
HTMLAttributes: {
19+
class: 'font-bold'
20+
}
21+
},
22+
heading: {
23+
levels: [1, 2, 3]
24+
}
25+
}),
26+
Markdown.configure({
27+
html: false,
28+
transformPastedText: true,
29+
transformCopiedText: true
30+
})
31+
],
32+
content,
33+
editable: false,
34+
editorProps: {
35+
attributes: {
36+
class: 'prose prose-sm dark:prose-invert focus:outline-none max-w-none p-4'
37+
}
38+
}
39+
});
40+
41+
// Update editor content when content prop changes
42+
React.useEffect(() => {
43+
if (editor && content !== undefined) {
44+
// Only update if content actually changed
45+
if (editor.storage.markdown.getMarkdown() !== content) {
46+
editor.commands.setContent(content);
47+
}
48+
}
49+
}, [editor, content]);
50+
51+
if (!editor) {
52+
return null;
53+
}
54+
55+
return (
56+
<div className={`[&_.ProseMirror]:focus-visible:outline-none [&_.ProseMirror]:focus-visible:ring-0 ${className}`}>
57+
<EditorContent
58+
editor={editor}
59+
className="[&_ul]:list-disc [&_ul]:ml-4 [&_ol]:list-decimal [&_ol]:ml-4 [&_h1]:text-3xl [&_h1]:font-bold [&_h1]:mb-4 [&_h2]:text-2xl [&_h2]:font-bold [&_h2]:mb-3 [&_h3]:text-xl [&_h3]:font-bold [&_h3]:mb-2"
60+
/>
61+
</div>
62+
)
63+
}

0 commit comments

Comments
 (0)