Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@
"@tippyjs/react": "^4.2.6",
"@tiptap/core": "^3.15.0",
"@tiptap/extension-character-count": "^3.14.0",
"@tiptap/extension-image": "^3.14.0",
"@tiptap/extension-link": "^3.14.0",
"@tiptap/extension-placeholder": "^3.14.0",
"@tiptap/react": "^3.14.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import type {
import React, { forwardRef, useRef } from 'react';
import classNames from 'classnames';
import { defaultMarkdownCommands } from '../../../hooks/input';
import MarkdownInput from './index';
import type { RichTextInputRef } from '../RichTextInput';
import RichTextInput from '../RichTextInput';
import type { Comment } from '../../../graphql/comments';
import { formToJson } from '../../../lib/form';
import type { Post } from '../../../graphql/posts';
Expand Down Expand Up @@ -64,7 +65,7 @@ export function CommentMarkdownInputComponent(
const {
mutateComment: { mutateComment, isLoading, isSuccess },
} = useWriteCommentContext();
const markdownRef = useRef<{ clearDraft: () => void }>(null);
const richTextRef = useRef<RichTextInputRef>(null);

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

// Clear draft after successful submission
if (result && markdownRef.current) {
markdownRef.current.clearDraft();
if (result && richTextRef.current) {
richTextRef.current.clearDraft();
}

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

// Clear draft after successful submission
if (result && markdownRef.current) {
markdownRef.current.clearDraft();
if (result && richTextRef.current) {
richTextRef.current.clearDraft();
}

return result;
Expand All @@ -111,21 +112,20 @@ export function CommentMarkdownInputComponent(
style={style}
ref={ref}
>
<MarkdownInput
ref={(markdownRefInstance) => {
if (markdownRefInstance) {
markdownRef.current = markdownRefInstance;
<RichTextInput
ref={(richTextRefInstance) => {
if (richTextRefInstance) {
richTextRef.current = richTextRefInstance;
if (shouldFocus.current) {
markdownRefInstance.textareaRef.current.focus();
richTextRefInstance.focus();
shouldFocus.current = false;
}
}
}}
className={{
tab: classNames('!min-h-16', className?.tab),
container: classNames('!min-h-16', className?.markdownContainer),
input: classNames(className?.input, replyTo && 'mt-0'),
profile: replyTo && '!mt-0',
container: className?.markdownContainer,
}}
postId={postId}
sourceId={sourceId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,12 @@ export const LinkModal = ({
const handleSubmit = useCallback(
(e: FormEvent) => {
e.preventDefault();
e.stopPropagation();

if (!url.trim()) {
return;
}

// Add https:// if no protocol specified
let finalUrl = url.trim();
if (!/^https?:\/\//i.test(finalUrl)) {
finalUrl = `https://${finalUrl}`;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ReactElement, Ref } from 'react';
import type { ReactElement, ReactNode, Ref } from 'react';
import React, {
useState,
useCallback,
Expand All @@ -23,6 +23,8 @@ import { LinkModal } from './LinkModal';
export interface RichTextToolbarProps {
editor: Editor;
onLinkAdd: (url: string, label?: string) => void;
inlineActions?: ReactNode;
rightActions?: ReactNode;
}

export interface RichTextToolbarRef {
Expand Down Expand Up @@ -63,7 +65,7 @@ const ToolbarButton = ({
};

function RichTextToolbarComponent(
{ editor, onLinkAdd }: RichTextToolbarProps,
{ editor, onLinkAdd, inlineActions, rightActions }: RichTextToolbarProps,
ref: Ref<RichTextToolbarRef>,
): ReactElement {
const [isLinkModalOpen, setIsLinkModalOpen] = useState(false);
Expand Down Expand Up @@ -152,6 +154,12 @@ function RichTextToolbarComponent(
isActive={editor.isActive('link')}
onClick={openLinkModal}
/>
{inlineActions && (
<>
<div className="mx-1 h-4 w-px bg-border-subtlest-tertiary" />
<div className="flex items-center gap-1">{inlineActions}</div>
</>
)}
{(editor.can().undo() || editor.can().redo()) && (
<div className="mx-1 h-4 w-px bg-border-subtlest-tertiary" />
)}
Expand All @@ -169,6 +177,9 @@ function RichTextToolbarComponent(
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
/>
{rightActions && (
<div className="ml-auto flex items-center gap-1">{rightActions}</div>
)}
</div>
<LinkModal
isOpen={isLinkModalOpen}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import CharacterCount from '@tiptap/extension-character-count';
import classNames from 'classnames';
import type { RichTextToolbarRef } from './RichTextToolbar';
import { RichTextToolbar } from './RichTextToolbar';
import { MarkdownInputRules } from './markdownInputRules';
import styles from './richtext.module.css';

export interface RichTextRef {
Expand Down Expand Up @@ -69,11 +70,8 @@ function RichTextEditorComponent(
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: false,
codeBlock: false,
blockquote: false,
horizontalRule: false,
code: false,
}),
Link.configure({
openOnClick: false,
Expand All @@ -88,6 +86,7 @@ function RichTextEditorComponent(
CharacterCount.configure({
limit: maxLength,
}),
MarkdownInputRules,
LinkShortcut,
],
content: initialContent,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Extension, markInputRule, nodeInputRule } from '@tiptap/core';

const linkRegex = /\[([^\]]+)\]\((?:[^)]+)\)$/;
const imageRegex = /!\[[^\]]*\]\((?:[^)]+)\)$/;

const extractUrl = (value: string): string | null => {
const match = value.match(/\(([^)]+)\)\s*$/);
return match?.[1]?.trim() ?? null;
};

const extractAlt = (value: string): string =>
value.match(/^!\[([^\]]*)\]/)?.[1] ?? '';

export const MarkdownInputRules = Extension.create({
name: 'markdownInputRules',
addInputRules() {
const rules = [];
const linkType = this.editor.schema.marks.link;
const imageType = this.editor.schema.nodes.image;

if (linkType) {
rules.push(
markInputRule({
find: linkRegex,
type: linkType,
getAttributes: (match) => {
const url = extractUrl(match[0]);
if (!url) {
return false;
}
return { href: url };
},
}),
);
}

if (imageType) {
rules.push(
nodeInputRule({
find: imageRegex,
type: imageType,
getAttributes: (match) => {
const url = extractUrl(match[0]);
return {
src: url ?? '',
alt: extractAlt(match[0]),
};
},
}),
);
}

return rules;
},
});

export default MarkdownInputRules;
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,50 @@
@apply font-bold;
}

& :where(h1) {
@apply my-3 text-text-primary typo-title1 font-bold;
}

& :where(h2) {
@apply my-3 text-text-primary typo-title2 font-bold;
}

& :where(h3) {
@apply my-3 text-text-primary typo-title3 font-bold;
}

& :where(h4) {
@apply my-3 text-text-primary typo-body font-bold;
}

& :where(h5) {
@apply my-2 text-text-primary typo-body font-bold;
}

& :where(h6) {
@apply my-2 text-text-primary typo-callout font-bold;
}

& :where(em) {
@apply italic;
}

& :where(code) {
@apply rounded-6 bg-surface-float px-1 py-0.5 font-mono text-text-primary;
}

& :where(pre) {
@apply my-3 overflow-x-auto rounded-12 bg-surface-float p-3;
}

& :where(pre code) {
@apply bg-transparent p-0;
}

& :where(img) {
@apply max-w-full rounded-8;
}

/* Placeholder styling */
& :global(.is-editor-empty:first-child::before) {
@apply text-text-quaternary pointer-events-none float-left h-0;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { generateStorageKey, StorageTopic } from '../../../lib/storage';
import { storageWrapper } from '../../../lib/storageWrapper';

interface UseDraftStorageProps {
postId?: string;
editCommentId?: string;
parentCommentId?: string;
content: string;
isDirty: boolean;
}

export function useDraftStorage({
postId,
editCommentId,
parentCommentId,
content,
isDirty,
}: UseDraftStorageProps) {
const saveTimeoutRef = useRef<NodeJS.Timeout>();

const draftStorageKey = useMemo(() => {
if (!postId) {
return null;
}
const identifier = editCommentId || parentCommentId || postId;
return generateStorageKey(StorageTopic.Comment, 'draft', identifier);
}, [postId, editCommentId, parentCommentId]);

const getInitialValue = useCallback(
(initialContent: string) => {
if (initialContent) {
return initialContent;
}
if (!draftStorageKey) {
return '';
}

return storageWrapper.getItem(draftStorageKey) || '';
},
[draftStorageKey],
);

const clearDraft = useCallback(() => {
if (draftStorageKey) {
storageWrapper.removeItem(draftStorageKey);
}
}, [draftStorageKey]);

useEffect(() => {
if (!draftStorageKey || !isDirty) {
return undefined;
}

if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}

saveTimeoutRef.current = setTimeout(() => {
if (content && content.trim().length > 0) {
storageWrapper.setItem(draftStorageKey, content);
} else {
storageWrapper.removeItem(draftStorageKey);
}
}, 500);

return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, [content, draftStorageKey, isDirty]);

return {
draftStorageKey,
getInitialValue,
clearDraft,
};
}
Loading