chore(input): use tiptap editer

This commit is contained in:
molvqingtai 2023-09-15 02:03:17 +08:00
parent 1ca18504c4
commit 4394cc781a
11 changed files with 736 additions and 947 deletions

View file

@ -91,18 +91,21 @@
"@radix-ui/react-scroll-area": "^1.0.4",
"@radix-ui/react-slot": "^1.0.2",
"@tailwindcss/typography": "^0.5.9",
"@tiptap/extension-character-count": "^2.1.8",
"@tiptap/extension-highlight": "^2.1.7",
"@tiptap/extension-typography": "^2.1.7",
"@tiptap/pm": "^2.1.7",
"@tiptap/react": "^2.1.7",
"@tiptap/starter-kit": "^2.1.7",
"class-variance-authority": "^0.6.1",
"clsx": "^1.2.1",
"date-fns": "^2.30.0",
"idb-keyval": "^6.2.1",
"lucide-react": "^0.263.0",
"mem": "^9.0.2",
"nanoid": "^4.0.2",
"peerjs": "^1.4.7",
"react-markdown": "^8.0.7",
"react-nice-avatar": "^1.4.1",
"react-use": "^17.4.0",
"remark-gfm": "^3.0.1",
"remesh": "^4.2.0",
"remesh-logger": "^4.1.0",
"remesh-react": "^4.1.0",

File diff suppressed because it is too large Load diff

View file

@ -23,14 +23,14 @@ const LikeButton: FC<LikeButtonProps> & { Icon: FC<LikeButtonIconProps> } = ({
onChange,
children
}) => {
const handleOnClick = (e: MouseEvent<HTMLButtonElement>) => {
const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
onClick?.(e)
onChange?.(!checked, checked ? count - 1 : count + 1)
}
return (
<Button
onClick={handleOnClick}
onClick={handleClick}
variant="secondary"
className={cn(
'grid items-center overflow-hidden rounded-full leading-none transition-all',

View file

@ -1,52 +1,65 @@
import { type FC, type ChangeEvent, type KeyboardEvent } from 'react'
import { type FC } from 'react'
import { Textarea } from '@/components/ui/Textarea'
import { Markdown } from '@/components/ui/Markdown'
import Highlight from '@tiptap/extension-highlight'
import Typography from '@tiptap/extension-typography'
import { EditorContent, Extension, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import CharacterCount from '@tiptap/extension-character-count'
import { cn } from '@/utils'
export interface MessageInputProps {
value?: string
className?: string
maxLength?: number
preview?: boolean
enterClear?: boolean
onInput?: (value: string) => void
onEnter?: (value: string) => void
}
const MessageInput: FC<MessageInputProps> = ({ value = '', className, maxLength = 500, onInput, onEnter, preview }) => {
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !(e.shiftKey || e.ctrlKey || e.altKey || e.metaKey)) {
e.preventDefault()
onEnter?.(value)
const MessageInput: FC<MessageInputProps> = ({
value = '',
className,
maxLength = 500,
enterClear = false,
onInput,
onEnter
}) => {
const editor = useEditor({
extensions: [
StarterKit,
Highlight,
Typography,
CharacterCount.configure({
limit: maxLength
}),
Extension.create({
addKeyboardShortcuts: () => ({
Enter: ({ editor }) => {
onEnter?.(editor.getHTML())
enterClear && editor.commands.clearContent()
return true
}
})
})
],
content: value,
editorProps: {
attributes: {
class: cn(
'prose prose-sm prose-slate box-border text-sm break-words max-h-28 overflow-y-auto overflow-x-hidden min-h-[60px] w-full rounded-lg border border-input bg-gray-50 px-3 py-2 pb-5 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 2xl:max-h-4'
)
}
const handleInput = (e: ChangeEvent<HTMLTextAreaElement>) => {
onInput?.(e.target.value)
},
onUpdate({ editor }) {
onInput?.(editor.getHTML())
}
})
return (
<div className={cn('relative', className)}>
{preview ? (
<Markdown className="max-h-32 rounded-lg border border-input bg-gray-50 2xl:max-h-40">{value}</Markdown>
) : (
// Hack: Auto-Growing Textarea
<div
data-value={value}
className="grid after:pointer-events-none after:invisible after:col-start-1 after:col-end-2 after:row-start-1 after:row-end-2 after:box-border after:max-h-28 after:w-full after:overflow-x-hidden after:whitespace-pre-wrap after:break-words after:rounded-lg after:border after:px-3 after:py-2 after:pb-5 after:text-sm after:content-[attr(data-value)] after:2xl:max-h-40"
>
<Textarea
onKeyDown={handleKeyDown}
maxLength={maxLength}
className="col-start-1 col-end-2 row-start-1 row-end-2 box-border max-h-28 resize-none overflow-x-hidden break-words rounded-lg bg-gray-50 pb-5 text-sm 2xl:max-h-40"
rows={2}
value={value}
placeholder="Type your message here."
onInput={handleInput}
/>
</div>
)}
<EditorContent editor={editor} />
<div className="absolute bottom-1 right-3 rounded-lg text-xs text-slate-400">
{value?.length ?? 0}/{maxLength}
{editor?.storage.characterCount.characters()}/{maxLength}
</div>
</div>
)

View file

@ -5,7 +5,6 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/Avatar'
import LikeButton from '@/components/LikeButton'
import { type Message } from '@/types'
import { Markdown } from '@/components/ui/Markdown'
export interface MessageItemProps {
data: Message
@ -39,9 +38,7 @@ const MessageItem: FC<MessageItemProps> = ({ data }) => {
<div className="text-xs text-slate-400">{formatData.date}</div>
</div>
<div>
<div className="pb-2">
<Markdown>{formatData.body}</Markdown>
</div>
<div className="prose prose-sm prose-slate pb-2" dangerouslySetInnerHTML={{ __html: formatData.body }}></div>
<div className="grid grid-flow-col justify-end gap-x-2 leading-none">
<LikeButton
checked={formatData.likeChecked}

View file

@ -1,70 +0,0 @@
import { type FC } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { cn } from '@/utils'
export interface MarkdownProps {
children?: string
className?: string
}
const Markdown: FC<MarkdownProps> = ({ children = '', className }) => {
return (
<ReactMarkdown
components={{
h1: ({ className, ...props }) => <h1 className={cn('mb-2 mt-0 text-2xl', className)} {...props} />,
h2: ({ className, ...props }) => <h2 className={cn('mb-2 mt-0', className)} {...props} />,
img: ({ className, alt, ...props }) => (
<img className={cn('my-2 max-w-[50%] rounded-md border', className)} alt={alt} {...props} />
),
ul: ({ className, ...props }) => {
Reflect.deleteProperty(props, 'ordered')
return <ul className={cn('text-sm [&:not([depth="0"])]:my-0 ', className)} {...props} />
},
input: ({ className, ...props }) => <input className={cn('my-0', className)} {...props} />,
table: ({ className, ...props }) => (
<div className="my-4 w-full overflow-y-auto">
<table className={cn('my-0 w-full rounded-md', className)} {...props} />
</div>
),
tr: ({ className, ...props }) => {
// fix: spell it as lowercase `isheader` warning
Reflect.deleteProperty(props, 'isHeader')
return <tr className={cn('m-0 border-t p-0 even:bg-muted', className)} {...props} />
},
th: ({ className, ...props }) => {
// fix: spell it as lowercase `isheader` warning
Reflect.deleteProperty(props, 'isHeader')
return (
<th
className={cn(
'border px-3 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right',
className
)}
{...props}
/>
)
},
td: ({ className, ...props }) => {
return (
<td
className={cn(
'border px-3 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right',
className
)}
{...props}
/>
)
}
}}
remarkPlugins={[remarkGfm]}
className={cn(className, 'prose prose-sm prose-slate')}
>
{children}
</ReactMarkdown>
)
}
Markdown.displayName = 'Markdown'
export { Markdown }

View file

@ -1,21 +0,0 @@
import * as React from 'react'
import { cn } from '@/utils/index'
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = 'Textarea'
export { Textarea }

View file

@ -12,39 +12,6 @@ const MessageInputDomain = Remesh.domain({
const MessageQuery = MessageInputModule.query.ValueQuery
const PreviewState = domain.state({
name: 'MessageInput.PreviewState',
default: false
})
const PreviewQuery = domain.query({
name: 'MessageInput.PreviewQuery',
impl: ({ get }) => {
return get(PreviewState())
}
})
const PreviewCommand = domain.command({
name: 'MessageInput.PreviewCommand',
impl: (_, value: boolean) => {
return PreviewState().new(value)
}
})
const EnterEvent = domain.event({
name: 'MessageInput.EnterEvent',
impl: ({ get }) => {
return get(MessageInputModule.query.ValueQuery())
}
})
const EnterCommand = domain.command({
name: 'MessageInput.EnterCommand',
impl: () => {
return EnterEvent()
}
})
const ClearCommand = domain.command({
name: 'MessageInput.ClearCommand',
impl: () => {
@ -54,18 +21,14 @@ const MessageInputDomain = Remesh.domain({
return {
query: {
MessageQuery,
PreviewQuery
MessageQuery
},
command: {
...MessageInputModule.command,
EnterCommand,
ClearCommand,
PreviewCommand
ClearCommand
},
event: {
...MessageInputModule.event,
EnterEvent
...MessageInputModule.event
}
}
}

View file

@ -2,22 +2,18 @@ import { Remesh } from 'remesh'
import { ListModule } from 'remesh/modules/list'
import { nanoid } from 'nanoid'
import { from, map, tap, merge } from 'rxjs'
import mem from 'mem'
import Storage from './externs/Storage'
export interface Message {
id: string
[key: string]: any
}
import { type Message } from '@/types'
const MessageListDomain = <T extends Message>() =>
Remesh.domain({
const MessageListDomain = Remesh.domain({
name: 'MessageListDomain',
impl: (domain) => {
const storage = domain.getExtern(Storage)
const storageKey = `${storage.name}.MESSAGE_LIST`
const MessageListModule = ListModule<T>(domain, {
const MessageListModule = ListModule<Message>(domain, {
name: 'MessageListModule',
key: (message) => message.id
})
@ -33,25 +29,25 @@ const MessageListDomain = <T extends Message>() =>
}
})
const CreateItemEvent = domain.event<T>({
const CreateItemEvent = domain.event<Message>({
name: 'MessageList.CreateItemEvent'
})
const CreateItemCommand = domain.command({
name: 'MessageList.CreateItemCommand',
impl: (_, message: Omit<T, 'id'>) => {
const newMessage = { ...message, id: nanoid() } as T
impl: (_, message: Omit<Message, 'id'>) => {
const newMessage = { ...message, id: nanoid() }
return [MessageListModule.command.AddItemCommand(newMessage), CreateItemEvent(newMessage), ChangeListEvent()]
}
})
const UpdateItemEvent = domain.event<T>({
const UpdateItemEvent = domain.event<Message>({
name: 'MessageList.UpdateItemEvent'
})
const UpdateItemCommand = domain.command({
name: 'MessageList.UpdateItemCommand',
impl: (_, message: T) => {
impl: (_, message: Message) => {
return [MessageListModule.command.UpdateItemCommand(message), UpdateItemEvent(message), ChangeListEvent()]
}
})
@ -78,13 +74,13 @@ const MessageListDomain = <T extends Message>() =>
}
})
const InitListEvent = domain.event<T[]>({
const InitListEvent = domain.event<Message[]>({
name: 'MessageList.InitListEvent'
})
const InitListCommand = domain.command({
name: 'MessageList.InitListCommand',
impl: (_, messages: T[]) => {
impl: (_, messages: Message[]) => {
return [MessageListModule.command.SetListCommand(messages), InitListEvent(messages)]
}
})
@ -92,7 +88,7 @@ const MessageListDomain = <T extends Message>() =>
domain.effect({
name: 'FormStorageToStateEffect',
impl: () => {
return from(storage.get<T[]>(storageKey)).pipe(map((messages) => InitListCommand(messages ?? [])))
return from(storage.get<Message[]>(storageKey)).pipe(map((messages) => InitListCommand(messages ?? [])))
}
})
@ -100,7 +96,7 @@ const MessageListDomain = <T extends Message>() =>
name: 'FormStateToStorageEffect',
impl: ({ fromEvent }) => {
const createItem$ = fromEvent(ChangeListEvent).pipe(
tap(async (messages) => await storage.set<T[]>(storageKey, messages))
tap(async (messages) => await storage.set<Message[]>(storageKey, messages))
)
return merge(createItem$).pipe(map(() => null))
}
@ -125,6 +121,6 @@ const MessageListDomain = <T extends Message>() =>
}
}
}
})()
})
export default mem(MessageListDomain)
export default MessageListDomain

View file

@ -6,18 +6,12 @@ import MessageInput from '@/components/MessageInput'
import MessageInputDomain from '@/domain/MessageInput'
import MessageListDomain from '@/domain/MessageList'
import { MESSAGE_MAX_LENGTH } from '@/constants'
import { type Message } from '@/types'
const Footer: FC = () => {
const send = useRemeshSend()
const messageListDomain = useRemeshDomain(MessageListDomain<Message>())
const messageListDomain = useRemeshDomain(MessageListDomain())
const messageInputDomain = useRemeshDomain(MessageInputDomain())
const text = useRemeshQuery(messageInputDomain.query.MessageQuery())
const isPreview = useRemeshQuery(messageInputDomain.query.PreviewQuery())
const handleInput = (value: string) => {
send(messageInputDomain.command.InputCommand(value))
}
const message = {
username: '墨绿青苔',
@ -30,16 +24,19 @@ const Footer: FC = () => {
hateCount: 0
}
const handleInput = (value: string) => {
send(messageInputDomain.command.InputCommand(value))
}
const handleSend = () => {
send(messageListDomain.command.CreateItemCommand(message))
send(messageInputDomain.command.ClearCommand())
}
return (
<div className="grid gap-y-2 px-4 pb-4">
<MessageInput
value={text}
preview={isPreview}
enterClear={true}
onEnter={handleSend}
onInput={handleInput}
maxLength={MESSAGE_MAX_LENGTH}

View file

@ -3,10 +3,9 @@ import { useRemeshDomain, useRemeshQuery } from 'remesh-react'
import MessageList from '@/components/MessageList'
import MessageItem from '@/components/MessageItem'
import MessageListDomain from '@/domain/MessageList'
import { type Message } from '@/types'
const Main: FC = () => {
const messageListDomain = useRemeshDomain(MessageListDomain<Message>())
const messageListDomain = useRemeshDomain(MessageListDomain())
const messageList = useRemeshQuery(messageListDomain.query.ListQuery())
return (