Skip to content

Commit ee6fd88

Browse files
rebelchrisclaudegithub-actions[bot]
authored
feat(shared): replace MarkdownInput with RichTextInput (#5457)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Chris Bongers <rebelchris@users.noreply.github.com>
1 parent 251d79d commit ee6fd88

25 files changed

+1802
-46
lines changed

packages/shared/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@
119119
"@tippyjs/react": "^4.2.6",
120120
"@tiptap/core": "^3.15.0",
121121
"@tiptap/extension-character-count": "^3.14.0",
122+
"@tiptap/extension-image": "^3.14.0",
122123
"@tiptap/extension-link": "^3.14.0",
123124
"@tiptap/extension-placeholder": "^3.14.0",
124125
"@tiptap/react": "^3.14.0",

packages/shared/src/components/cards/poll/PollOptions.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ const PollResults = ({
3838
);
3939

4040
useEffect(() => {
41-
let timer: NodeJS.Timeout;
41+
let timer: ReturnType<typeof setTimeout>;
4242

4343
if (shouldAnimate) {
4444
timer = setTimeout(() => {

packages/shared/src/components/fields/MarkdownInput/CommentMarkdownInput.tsx

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import type {
88
import React, { forwardRef, useRef } from 'react';
99
import classNames from 'classnames';
1010
import { defaultMarkdownCommands } from '../../../hooks/input';
11-
import MarkdownInput from './index';
11+
import type { RichTextInputRef } from '../RichTextInput';
12+
import RichTextInput from '../RichTextInput';
1213
import type { Comment } from '../../../graphql/comments';
1314
import { formToJson } from '../../../lib/form';
1415
import type { Post } from '../../../graphql/posts';
@@ -64,7 +65,7 @@ export function CommentMarkdownInputComponent(
6465
const {
6566
mutateComment: { mutateComment, isLoading, isSuccess },
6667
} = useWriteCommentContext();
67-
const markdownRef = useRef<{ clearDraft: () => void }>(null);
68+
const richTextRef = useRef<RichTextInputRef>(null);
6869

6970
const onSubmitForm: FormEventHandler<HTMLFormElement> = async (e) => {
7071
e.preventDefault();
@@ -78,8 +79,8 @@ export function CommentMarkdownInputComponent(
7879
const result = await mutateComment(content);
7980

8081
// Clear draft after successful submission
81-
if (result && markdownRef.current) {
82-
markdownRef.current.clearDraft();
82+
if (result && richTextRef.current) {
83+
richTextRef.current.clearDraft();
8384
}
8485

8586
return result;
@@ -95,8 +96,8 @@ export function CommentMarkdownInputComponent(
9596
const result = await mutateComment(content);
9697

9798
// Clear draft after successful submission
98-
if (result && markdownRef.current) {
99-
markdownRef.current.clearDraft();
99+
if (result && richTextRef.current) {
100+
richTextRef.current.clearDraft();
100101
}
101102

102103
return result;
@@ -111,21 +112,20 @@ export function CommentMarkdownInputComponent(
111112
style={style}
112113
ref={ref}
113114
>
114-
<MarkdownInput
115-
ref={(markdownRefInstance) => {
116-
if (markdownRefInstance) {
117-
markdownRef.current = markdownRefInstance;
115+
<RichTextInput
116+
ref={(richTextRefInstance) => {
117+
if (richTextRefInstance) {
118+
richTextRef.current = richTextRefInstance;
118119
if (shouldFocus.current) {
119-
markdownRefInstance.textareaRef.current.focus();
120+
richTextRefInstance.focus();
120121
shouldFocus.current = false;
121122
}
122123
}
123124
}}
124125
className={{
125-
tab: classNames('!min-h-16', className?.tab),
126+
container: classNames('!min-h-16', className?.markdownContainer),
126127
input: classNames(className?.input, replyTo && 'mt-0'),
127128
profile: replyTo && '!mt-0',
128-
container: className?.markdownContainer,
129129
}}
130130
postId={postId}
131131
sourceId={sourceId}

packages/shared/src/components/fields/RichTextEditor/LinkModal.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,12 @@ export const LinkModal = ({
3333
const handleSubmit = useCallback(
3434
(e: FormEvent) => {
3535
e.preventDefault();
36+
e.stopPropagation();
37+
3638
if (!url.trim()) {
3739
return;
3840
}
3941

40-
// Add https:// if no protocol specified
4142
let finalUrl = url.trim();
4243
if (!/^https?:\/\//i.test(finalUrl)) {
4344
finalUrl = `https://${finalUrl}`;

packages/shared/src/components/fields/RichTextEditor/RichTextToolbar.tsx

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import type { ReactElement, Ref } from 'react';
1+
import type { ReactElement, ReactNode, Ref } from 'react';
22
import React, {
33
useState,
44
useCallback,
55
forwardRef,
66
useImperativeHandle,
77
} from 'react';
8+
import { useEditorState } from '@tiptap/react';
89
import type { Editor } from '@tiptap/react';
910
import { getMarkRange } from '@tiptap/core';
1011
import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button';
@@ -23,6 +24,8 @@ import { LinkModal } from './LinkModal';
2324
export interface RichTextToolbarProps {
2425
editor: Editor;
2526
onLinkAdd: (url: string, label?: string) => void;
27+
inlineActions?: ReactNode;
28+
rightActions?: ReactNode;
2629
}
2730

2831
export interface RichTextToolbarRef {
@@ -63,7 +66,7 @@ const ToolbarButton = ({
6366
};
6467

6568
function RichTextToolbarComponent(
66-
{ editor, onLinkAdd }: RichTextToolbarProps,
69+
{ editor, onLinkAdd, inlineActions, rightActions }: RichTextToolbarProps,
6770
ref: Ref<RichTextToolbarRef>,
6871
): ReactElement {
6972
const [isLinkModalOpen, setIsLinkModalOpen] = useState(false);
@@ -117,58 +120,80 @@ function RichTextToolbarComponent(
117120
setIsLinkModalOpen(false);
118121
}, []);
119122

123+
const editorState = useEditorState({
124+
editor,
125+
selector: ({ editor: currentEditor }) => ({
126+
isBold: currentEditor.isActive('bold'),
127+
isItalic: currentEditor.isActive('italic'),
128+
isBulletList: currentEditor.isActive('bulletList'),
129+
isOrderedList: currentEditor.isActive('orderedList'),
130+
isLink: currentEditor.isActive('link'),
131+
canUndo: currentEditor.can().undo(),
132+
canRedo: currentEditor.can().redo(),
133+
}),
134+
});
135+
120136
return (
121137
<>
122138
<div className="flex items-center gap-1 border-b border-border-subtlest-tertiary p-2">
123139
<ToolbarButton
124140
tooltip="Bold (⌘B)"
125141
icon={<BoldIcon />}
126-
isActive={editor.isActive('bold')}
142+
isActive={editorState.isBold}
127143
onClick={() => editor.chain().focus().toggleBold().run()}
128144
/>
129145
<ToolbarButton
130146
tooltip="Italic (⌘I)"
131147
icon={<ItalicIcon />}
132-
isActive={editor.isActive('italic')}
148+
isActive={editorState.isItalic}
133149
onClick={() => editor.chain().focus().toggleItalic().run()}
134150
/>
135151
<div className="mx-1 h-4 w-px bg-border-subtlest-tertiary" />
136152
<ToolbarButton
137153
tooltip="Bullet list (⌘⇧8)"
138154
icon={<BulletListIcon />}
139-
isActive={editor.isActive('bulletList')}
155+
isActive={editorState.isBulletList}
140156
onClick={() => editor.chain().focus().toggleBulletList().run()}
141157
/>
142158
<ToolbarButton
143159
tooltip="Numbered list (⌘⇧7)"
144160
icon={<NumberedListIcon />}
145-
isActive={editor.isActive('orderedList')}
161+
isActive={editorState.isOrderedList}
146162
onClick={() => editor.chain().focus().toggleOrderedList().run()}
147163
/>
148164
<div className="mx-1 h-4 w-px bg-border-subtlest-tertiary" />
149165
<ToolbarButton
150-
tooltip={editor.isActive('link') ? 'Edit link (⌘K)' : 'Add link (⌘K)'}
166+
tooltip={editorState.isLink ? 'Edit link (⌘K)' : 'Add link (⌘K)'}
151167
icon={<LinkIcon />}
152-
isActive={editor.isActive('link')}
168+
isActive={editorState.isLink}
153169
onClick={openLinkModal}
154170
/>
155-
{(editor.can().undo() || editor.can().redo()) && (
171+
{inlineActions && (
172+
<>
173+
<div className="mx-1 h-4 w-px bg-border-subtlest-tertiary" />
174+
<div className="flex items-center gap-1">{inlineActions}</div>
175+
</>
176+
)}
177+
{(editorState.canUndo || editorState.canRedo) && (
156178
<div className="mx-1 h-4 w-px bg-border-subtlest-tertiary" />
157179
)}
158180
<ToolbarButton
159181
tooltip="Undo (⌘Z)"
160182
icon={<UndoIcon />}
161183
isActive={false}
162184
onClick={() => editor.chain().focus().undo().run()}
163-
disabled={!editor.can().undo()}
185+
disabled={!editorState.canUndo}
164186
/>
165187
<ToolbarButton
166188
tooltip="Redo (⌘⇧Z)"
167189
icon={<RedoIcon />}
168190
isActive={false}
169191
onClick={() => editor.chain().focus().redo().run()}
170-
disabled={!editor.can().redo()}
192+
disabled={!editorState.canRedo}
171193
/>
194+
{rightActions && (
195+
<div className="ml-auto flex items-center gap-1">{rightActions}</div>
196+
)}
172197
</div>
173198
<LinkModal
174199
isOpen={isLinkModalOpen}

packages/shared/src/components/fields/RichTextEditor/index.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import CharacterCount from '@tiptap/extension-character-count';
1616
import classNames from 'classnames';
1717
import type { RichTextToolbarRef } from './RichTextToolbar';
1818
import { RichTextToolbar } from './RichTextToolbar';
19+
import { MarkdownInputRules } from './markdownInputRules';
1920
import styles from './richtext.module.css';
2021

2122
export interface RichTextRef {
@@ -69,11 +70,8 @@ function RichTextEditorComponent(
6970
const editor = useEditor({
7071
extensions: [
7172
StarterKit.configure({
72-
heading: false,
73-
codeBlock: false,
7473
blockquote: false,
7574
horizontalRule: false,
76-
code: false,
7775
}),
7876
Link.configure({
7977
openOnClick: false,
@@ -88,6 +86,7 @@ function RichTextEditorComponent(
8886
CharacterCount.configure({
8987
limit: maxLength,
9088
}),
89+
MarkdownInputRules,
9190
LinkShortcut,
9291
],
9392
content: initialContent,
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { Extension, markInputRule, nodeInputRule } from '@tiptap/core';
2+
3+
const linkRegex = /\[([^\]]+)\]\((?:[^)]+)\)$/;
4+
const imageRegex = /!\[[^\]]*\]\((?:[^)]+)\)$/;
5+
6+
const extractUrl = (value: string): string | null => {
7+
const match = value.match(/\(([^)]+)\)\s*$/);
8+
return match?.[1]?.trim() ?? null;
9+
};
10+
11+
const extractAlt = (value: string): string =>
12+
value.match(/^!\[([^\]]*)\]/)?.[1] ?? '';
13+
14+
export const MarkdownInputRules = Extension.create({
15+
name: 'markdownInputRules',
16+
addInputRules() {
17+
const rules = [];
18+
const linkType = this.editor.schema.marks.link;
19+
const imageType = this.editor.schema.nodes.image;
20+
21+
if (linkType) {
22+
rules.push(
23+
markInputRule({
24+
find: linkRegex,
25+
type: linkType,
26+
getAttributes: (match) => {
27+
const url = extractUrl(match[0]);
28+
if (!url) {
29+
return false;
30+
}
31+
return { href: url };
32+
},
33+
}),
34+
);
35+
}
36+
37+
if (imageType) {
38+
rules.push(
39+
nodeInputRule({
40+
find: imageRegex,
41+
type: imageType,
42+
getAttributes: (match) => {
43+
const url = extractUrl(match[0]);
44+
return {
45+
src: url ?? '',
46+
alt: extractAlt(match[0]),
47+
};
48+
},
49+
}),
50+
);
51+
}
52+
53+
return rules;
54+
},
55+
});
56+
57+
export default MarkdownInputRules;

packages/shared/src/components/fields/RichTextEditor/richtext.module.css

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,50 @@
2929
@apply font-bold;
3030
}
3131

32+
& :where(h1) {
33+
@apply my-3 text-text-primary typo-title1 font-bold;
34+
}
35+
36+
& :where(h2) {
37+
@apply my-3 text-text-primary typo-title2 font-bold;
38+
}
39+
40+
& :where(h3) {
41+
@apply my-3 text-text-primary typo-title3 font-bold;
42+
}
43+
44+
& :where(h4) {
45+
@apply my-3 text-text-primary typo-body font-bold;
46+
}
47+
48+
& :where(h5) {
49+
@apply my-2 text-text-primary typo-body font-bold;
50+
}
51+
52+
& :where(h6) {
53+
@apply my-2 text-text-primary typo-callout font-bold;
54+
}
55+
3256
& :where(em) {
3357
@apply italic;
3458
}
3559

60+
& :where(code) {
61+
@apply rounded-6 bg-surface-float px-1 py-0.5 font-mono text-text-primary;
62+
}
63+
64+
& :where(pre) {
65+
@apply my-3 overflow-x-auto rounded-12 bg-surface-float p-3;
66+
}
67+
68+
& :where(pre code) {
69+
@apply bg-transparent p-0;
70+
}
71+
72+
& :where(img) {
73+
@apply max-w-full rounded-8;
74+
}
75+
3676
/* Placeholder styling */
3777
& :global(.is-editor-empty:first-child::before) {
3878
@apply text-text-quaternary pointer-events-none float-left h-0;

0 commit comments

Comments
 (0)