chore(input): use tiptap editer
This commit is contained in:
parent
1ca18504c4
commit
4394cc781a
11 changed files with 736 additions and 947 deletions
|
@ -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",
|
||||
|
|
1232
pnpm-lock.yaml
1232
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
@ -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',
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 }
|
|
@ -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 }
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 (
|
||||
|
|
Loading…
Reference in a new issue