refactor: refactoring MessageInput

This commit is contained in:
molvqingtai 2023-08-01 03:14:59 +08:00
parent 129129c455
commit 558540a361
11 changed files with 338 additions and 60 deletions

View file

@ -100,6 +100,10 @@
"react-nice-avatar": "^1.4.1", "react-nice-avatar": "^1.4.1",
"react-use": "^17.4.0", "react-use": "^17.4.0",
"remark-gfm": "^3.0.1", "remark-gfm": "^3.0.1",
"remesh": "^4.2.0",
"remesh-logger": "^4.1.0",
"remesh-react": "^4.1.0",
"rxjs": "^7.8.1",
"tailwind-merge": "^1.13.2", "tailwind-merge": "^1.13.2",
"type-fest": "^3.13.0" "type-fest": "^3.13.0"
}, },

View file

@ -53,6 +53,18 @@ dependencies:
remark-gfm: remark-gfm:
specifier: ^3.0.1 specifier: ^3.0.1
version: 3.0.1 version: 3.0.1
remesh:
specifier: ^4.2.0
version: 4.2.0(rxjs@7.8.1)
remesh-logger:
specifier: ^4.1.0
version: 4.1.0(remesh@4.2.0)
remesh-react:
specifier: ^4.1.0
version: 4.1.0(react-dom@18.2.0)(react@18.2.0)(remesh@4.2.0)(rxjs@7.8.1)
rxjs:
specifier: ^7.8.1
version: 7.8.1
tailwind-merge: tailwind-merge:
specifier: ^1.13.2 specifier: ^1.13.2
version: 1.13.2 version: 1.13.2
@ -4527,6 +4539,11 @@ packages:
engines: {node: '>=12'} engines: {node: '>=12'}
dev: false dev: false
/is-plain-object@5.0.0:
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
engines: {node: '>=0.10.0'}
dev: false
/is-regex@1.1.4: /is-regex@1.1.4:
resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -6726,6 +6743,53 @@ packages:
unified: 10.1.2 unified: 10.1.2
dev: false dev: false
/remesh-debugger-helper@4.1.0(remesh@4.2.0):
resolution: {integrity: sha512-nBbfznOEuu9Z1tpLIBOKEYcYrQvOCc+JyyPWYRG3H79JO/jxIUEAGg6YzAw59EUE/nhMPyx6qwyB7exJwFsZug==}
peerDependencies:
remesh: ^4.0.0
dependencies:
remesh: 4.2.0(rxjs@7.8.1)
tslib: 2.6.0
dev: false
/remesh-logger@4.1.0(remesh@4.2.0):
resolution: {integrity: sha512-OEmmBFk6KVtv0JahVhh29JbcF5IeMhxqiBaz2UAqY5x5d8W6UJtYmQFat2/2HUYV/Y1C+ooGi/kzh3Ka6NoyMQ==}
peerDependencies:
remesh: ^4.0.0
dependencies:
remesh: 4.2.0(rxjs@7.8.1)
remesh-debugger-helper: 4.1.0(remesh@4.2.0)
tslib: 2.6.0
dev: false
/remesh-react@4.1.0(react-dom@18.2.0)(react@18.2.0)(remesh@4.2.0)(rxjs@7.8.1):
resolution: {integrity: sha512-VaTZ2y3A84tiPNxb2/wgDt6i65zSfsxlAP1sTSDC+5C5vnhTy8Q8O8AaYJMOKqXUDtUNI9ld9Qu0wqfdxig/dg==}
peerDependencies:
react: ^16.10.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.10.0 || ^17.0.0 || ^18.0.0
remesh: ^4.0.0
rxjs: ^7.0.0
dependencies:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
remesh: 4.2.0(rxjs@7.8.1)
rxjs: 7.8.1
tslib: 2.6.0
use-sync-external-store: 1.2.0(react@18.2.0)
dev: false
/remesh@4.2.0(rxjs@7.8.1):
resolution: {integrity: sha512-t2xwtFYnEwrcWddxtEb/NF7kR+x62Er52gfk+sCa4HmaQC+J5zg41pYZz6TTl4PrDghs2et1tr1dYZpbYGAu6g==}
engines: {node: '>=14.x'}
peerDependencies:
rxjs: ^7.0.0
dependencies:
is-plain-object: 5.0.0
rxjs: 7.8.1
shallowequal: 1.1.0
tslib: 2.6.0
dev: false
/request@2.88.2: /request@2.88.2:
resolution: {integrity: sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==} resolution: {integrity: sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@ -6894,7 +6958,6 @@ packages:
resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==}
dependencies: dependencies:
tslib: 2.6.0 tslib: 2.6.0
dev: true
/sade@1.8.1: /sade@1.8.1:
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
@ -7017,6 +7080,10 @@ packages:
safe-buffer: 5.2.1 safe-buffer: 5.2.1
dev: true dev: true
/shallowequal@1.1.0:
resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==}
dev: false
/shebang-command@1.2.0: /shebang-command@1.2.0:
resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -7921,6 +7988,14 @@ packages:
punycode: 2.3.0 punycode: 2.3.0
dev: true dev: true
/use-sync-external-store@1.2.0(react@18.2.0):
resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
react: 18.2.0
dev: false
/util-deprecate@1.0.2: /util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}

View file

@ -1,58 +1,57 @@
import { type FC, type ChangeEvent } from 'react' import { type FC, type ChangeEvent, type KeyboardEvent } from 'react'
import { Textarea } from '@/components/ui/Textarea' import { Textarea } from '@/components/ui/Textarea'
import { Markdown } from '@/components/ui/Markdown' import { Markdown } from '@/components/ui/Markdown'
import { cn } from '@/utils' import { cn } from '@/utils'
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
import MessageInputDomain from '@/domain/MessageInput'
import { MESSAGE_MAX_LENGTH } from '@/constants'
export interface MessageInputProps { export interface MessageInputProps {
value?: string
focus?: boolean
preview?: boolean
className?: string className?: string
maxLength?: number maxLength?: number
onInput?: (message: string) => void
onChange?: (message: string) => void
onFocus?: () => void
onBlur?: () => void
} }
const MessageInput: FC<MessageInputProps> = ({ const MessageInput: FC<MessageInputProps> = ({ className }) => {
value, const send = useRemeshSend()
focus = true, const messageInputDomain = useRemeshDomain(MessageInputDomain())
className,
preview, const message = useRemeshQuery(messageInputDomain.query.ValueQuery())
maxLength = 500, const isPreview = useRemeshQuery(messageInputDomain.query.PreviewQuery())
onInput,
onChange, const handleInput = (e: ChangeEvent<HTMLTextAreaElement>) => {
onFocus, send(messageInputDomain.command.InputCommand(e.target.value))
onBlur }
}) => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !(e.shiftKey || e.ctrlKey || e.altKey || e.metaKey)) {
e.preventDefault()
send(messageInputDomain.command.EnterCommand())
}
}
return ( return (
<div className={cn('relative', className)}> <div className={cn('relative', className)}>
{preview ? ( {isPreview ? (
<Markdown className="max-h-32 rounded-lg border border-input bg-gray-50 2xl:max-h-40">{value}</Markdown> <Markdown className="max-h-32 rounded-lg border border-input bg-gray-50 2xl:max-h-40">{message}</Markdown>
) : ( ) : (
// Hack: Auto-Growing Textarea // Hack: Auto-Growing Textarea
<div <div
data-value={value} data-value={message}
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" 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 <Textarea
autoFocus={focus} onKeyDown={handleKeyDown}
maxLength={maxLength} maxLength={MESSAGE_MAX_LENGTH}
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" 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} rows={2}
value={value} value={message}
placeholder="Type your message here." placeholder="Type your message here."
onInput={(e: ChangeEvent<HTMLTextAreaElement>) => onInput?.(e.target.value)} onInput={handleInput}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => onChange?.(e.target.value)}
onFocus={onFocus}
onBlur={onBlur}
/> />
</div> </div>
)} )}
<div className="absolute bottom-1 right-3 rounded-lg text-xs text-slate-400"> <div className="absolute bottom-1 right-3 rounded-lg text-xs text-slate-400">
{value?.length ?? 0}/{maxLength} {message?.length ?? 0}/{MESSAGE_MAX_LENGTH}
</div> </div>
</div> </div>
) )

View file

@ -1,19 +1,14 @@
import { useState, type FC } from 'react' import { type FC } from 'react'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { SmileIcon, CornerDownLeftIcon, ImageIcon } from 'lucide-react' import { SmileIcon, CornerDownLeftIcon, ImageIcon } from 'lucide-react'
import MessageInput from './MessageInput' import MessageInput from './MessageInput'
const Footer: FC = () => { const Footer: FC = () => {
const [message, setMessage] = useState('') const handleSend = () => {}
const handleInput = (value: string) => {
setMessage(value)
}
return ( return (
<div className="grid gap-y-2 p-4"> <div className="grid gap-y-2 p-4">
<MessageInput value={message} onInput={handleInput}></MessageInput> <MessageInput></MessageInput>
<div className="grid grid-cols-[auto_auto_1fr] items-center justify-items-end"> <div className="grid grid-cols-[auto_auto_1fr] items-center justify-items-end">
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
<SmileIcon size={20} /> <SmileIcon size={20} />
@ -21,7 +16,7 @@ const Footer: FC = () => {
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
<ImageIcon size={20} /> <ImageIcon size={20} />
</Button> </Button>
<Button size="sm"> <Button size="sm" onClick={handleSend}>
<span className="mr-2">Send</span> <span className="mr-2">Send</span>
<CornerDownLeftIcon className="text-slate-400" size={12}></CornerDownLeftIcon> <CornerDownLeftIcon className="text-slate-400" size={12}></CornerDownLeftIcon>
</Button> </Button>

View file

@ -1,24 +1,28 @@
import { FrownIcon, type LucideIcon, ThumbsUpIcon } from 'lucide-react' import { type MouseEvent, type FC, type ReactElement } from 'react'
import { type MouseEvent, type FC } from 'react'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { cn } from '@/utils' import { cn } from '@/utils'
export interface LikeButtonIconProps {
children: JSX.Element
}
export const LikeButtonIcon: FC<LikeButtonIconProps> = ({ children }) => children
export interface LikeButtonProps { export interface LikeButtonProps {
type: 'like' | 'hate'
count: number count: number
checked: boolean checked: boolean
onClick?: (e: MouseEvent<HTMLButtonElement>) => void onClick?: (e: MouseEvent<HTMLButtonElement>) => void
onChange?: (checked: boolean, count: number) => void onChange?: (checked: boolean, count: number) => void
children: ReactElement<LikeButtonIconProps>
} }
const iconMapping: Record<LikeButtonProps['type'], LucideIcon> = { const LikeButton: FC<LikeButtonProps> & { Icon: FC<LikeButtonIconProps> } = ({
like: ThumbsUpIcon, checked,
hate: FrownIcon count,
} onClick,
onChange,
const LikeButton: FC<LikeButtonProps> = ({ type, checked, count, onClick, onChange }) => { children
const Icon = iconMapping[type] }) => {
const handleOnClick = (e: MouseEvent<HTMLButtonElement>) => { const handleOnClick = (e: MouseEvent<HTMLButtonElement>) => {
onClick?.(e) onClick?.(e)
onChange?.(!checked, checked ? count - 1 : count + 1) onChange?.(!checked, checked ? count - 1 : count + 1)
@ -35,12 +39,14 @@ const LikeButton: FC<LikeButtonProps> = ({ type, checked, count, onClick, onChan
)} )}
size="xs" size="xs"
> >
<Icon size={14} /> {children}
{!!count && <span className="min-w-0 text-xs">{count}</span>} {!!count && <span className="min-w-0 text-xs">{count}</span>}
</Button> </Button>
) )
} }
LikeButton.Icon = LikeButtonIcon
LikeButton.displayName = 'LikeButton' LikeButton.displayName = 'LikeButton'
export default LikeButton export default LikeButton

View file

@ -3,6 +3,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/Avatar'
import { format } from 'date-fns' import { format } from 'date-fns'
import LikeButton from './LikeButton' import LikeButton from './LikeButton'
import { FrownIcon, ThumbsUpIcon } from 'lucide-react'
export interface MessageProps { export interface MessageProps {
data: { data: {
id: string id: string
@ -50,17 +51,23 @@ const Message: FC<MessageProps> = ({ data }) => {
</div> </div>
<div className="grid grid-flow-col justify-end gap-x-2 leading-none"> <div className="grid grid-flow-col justify-end gap-x-2 leading-none">
<LikeButton <LikeButton
type="like"
checked={formatData.likeChecked} checked={formatData.likeChecked}
onChange={(...args) => handleLikeChange('like', ...args)} onChange={(...args) => handleLikeChange('like', ...args)}
count={formatData.likeCount} count={formatData.likeCount}
></LikeButton> >
<LikeButton.Icon>
<ThumbsUpIcon size={14}></ThumbsUpIcon>
</LikeButton.Icon>
</LikeButton>
<LikeButton <LikeButton
type="hate"
checked={formatData.hateChecked} checked={formatData.hateChecked}
onChange={(...args) => handleLikeChange('hate', ...args)} onChange={(...args) => handleLikeChange('hate', ...args)}
count={formatData.hateCount} count={formatData.hateCount}
></LikeButton> >
<LikeButton.Icon>
<FrownIcon size={14}></FrownIcon>
</LikeButton.Icon>
</LikeButton>
</div> </div>
</div> </div>
</div> </div>
@ -69,5 +76,4 @@ const Message: FC<MessageProps> = ({ data }) => {
} }
Message.displayName = 'Message' Message.displayName = 'Message'
export default Message export default Message

View file

@ -32,7 +32,7 @@ const Main: FC = () => {
avatar: 'https://github.com/shadcn.png', avatar: 'https://github.com/shadcn.png',
date: Date.now(), date: Date.now(),
likeChecked: false, likeChecked: false,
hateChecked: false, hateChecked: true,
likeCount: 9999, likeCount: 9999,
hateCount: 2 hateCount: 2
} }

View file

@ -15,3 +15,5 @@ export const BREAKPOINTS = {
'2xl': '1536px' '2xl': '1536px'
// => @media (min-width: 1536px) { ... } // => @media (min-width: 1536px) { ... }
} as const } as const
export const MESSAGE_MAX_LENGTH = 500 as const

View file

@ -0,0 +1,59 @@
import { Remesh } from 'remesh'
import InputModule from './modules/Input'
const MessageInputDomain = Remesh.domain({
name: 'MessageInputDomain',
impl: (domain) => {
const inputModule = InputModule(domain, {
name: 'MessageInput'
})
const PreviewState = domain.state({
name: 'PreviewState',
default: false
})
const PreviewQuery = domain.query({
name: 'PreviewQuery',
impl: ({ get }) => {
return get(PreviewState())
}
})
const PreviewCommand = domain.command({
name: 'PreviewCommand',
impl: (_, value: boolean) => {
return PreviewState().new(value)
}
})
const EnterEvent = domain.event({
name: 'EnterEvent'
})
const EnterCommand = domain.command({
name: 'EnterCommand',
impl: () => {
return EnterEvent()
}
})
return {
query: {
...inputModule.query,
PreviewQuery
},
command: {
...inputModule.command,
EnterCommand,
PreviewCommand
},
event: {
...inputModule.event,
EnterEvent
}
}
}
})
export default MessageInputDomain

123
src/domain/modules/Input.ts Normal file
View file

@ -0,0 +1,123 @@
import { Remesh, type Capitalize, type RemeshDomainContext } from 'remesh'
export interface InputModuleOptions {
name: Capitalize
value?: string
disabled?: boolean
}
const InputModule = (domain: RemeshDomainContext, options: InputModuleOptions) => {
const ValueState = domain.state({
name: `${options.name}.ValueState`,
default: options.value ?? ''
})
const ValueQuery = domain.query({
name: `${options.name}.ValueQuery`,
impl: ({ get }) => {
return get(ValueState())
}
})
const InputEvent = domain.event<string>({
name: `${options.name}.InputEvent`
})
const InputCommand = domain.command({
name: `${options.name}.InputCommand`,
impl: (_, value: string) => {
InputEvent(value)
return ValueState().new(value)
}
})
const ChangeEvent = domain.event<string>({
name: `${options.name}.ChangeEvent`
})
const ChangeCommand = domain.command({
name: `${options.name}.ChangeCommand`,
impl: (_, value: string) => {
ChangeEvent(value)
return ValueState().new(value)
}
})
const FocusState = domain.state({
name: `${options.name}.FocusState`,
default: false
})
const FocusQuery = domain.query({
name: `${options.name}.FocusQuery`,
impl: ({ get }) => {
return get(FocusState())
}
})
const FocusEvent = domain.event<boolean>({
name: `${options.name}.FocusEvent`
})
const BlurEvent = domain.event<boolean>({
name: `${options.name}.BlurEvent`
})
const BlurCommand = domain.command({
name: `${options.name}.BlurCommand`,
impl: () => {
BlurEvent(false)
return FocusState().new(false)
}
})
const FocusCommand = domain.command({
name: `${options.name}.FocusCommand`,
impl: () => {
FocusEvent(true)
return FocusState().new(true)
}
})
const DisabledState = domain.state({
name: `${options.name}.DisabledState`,
default: options.disabled ?? false
})
const DisabledQuery = domain.query({
name: `${options.name}.DisabledQuery`,
impl: ({ get }) => {
return get(DisabledState())
}
})
const DisabledCommand = domain.command({
name: `${options.name}.DisabledCommand`,
impl: (_, value: boolean) => {
return DisabledState().new(value)
}
})
return Remesh.module({
query: {
ValueQuery,
FocusQuery,
DisabledQuery
},
command: {
InputCommand,
ChangeCommand,
BlurCommand,
FocusCommand,
DisabledCommand
},
event: {
InputEvent,
ChangeEvent,
FocusEvent,
BlurEvent
}
})
}
export default InputModule

View file

@ -2,14 +2,23 @@ import React from 'react'
import App from './App' import App from './App'
import createShadowRoot from './createShadowRoot' import createShadowRoot from './createShadowRoot'
import style from './index.css?inline' import style from './index.css?inline'
import { RemeshRoot } from 'remesh-react'
import { RemeshLogger } from 'remesh-logger'
import { Remesh } from 'remesh'
void (async () => { void (async () => {
const store = Remesh.store({
inspectors: [RemeshLogger()]
})
createShadowRoot(__NAME__, { createShadowRoot(__NAME__, {
style: __DEV__ ? '' : style, style: __DEV__ ? '' : style,
mode: __DEV__ ? 'open' : 'closed' mode: __DEV__ ? 'open' : 'closed'
}).render( }).render(
<React.StrictMode> <React.StrictMode>
<RemeshRoot store={store}>
<App /> <App />
</RemeshRoot>
</React.StrictMode> </React.StrictMode>
) )