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-use": "^17.4.0",
"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",
"type-fest": "^3.13.0"
},

View file

@ -53,6 +53,18 @@ dependencies:
remark-gfm:
specifier: ^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:
specifier: ^1.13.2
version: 1.13.2
@ -4527,6 +4539,11 @@ packages:
engines: {node: '>=12'}
dev: false
/is-plain-object@5.0.0:
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
engines: {node: '>=0.10.0'}
dev: false
/is-regex@1.1.4:
resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==}
engines: {node: '>= 0.4'}
@ -6726,6 +6743,53 @@ packages:
unified: 10.1.2
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:
resolution: {integrity: sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==}
engines: {node: '>= 6'}
@ -6894,7 +6958,6 @@ packages:
resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==}
dependencies:
tslib: 2.6.0
dev: true
/sade@1.8.1:
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
@ -7017,6 +7080,10 @@ packages:
safe-buffer: 5.2.1
dev: true
/shallowequal@1.1.0:
resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==}
dev: false
/shebang-command@1.2.0:
resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==}
engines: {node: '>=0.10.0'}
@ -7921,6 +7988,14 @@ packages:
punycode: 2.3.0
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:
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 { Markdown } from '@/components/ui/Markdown'
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 {
value?: string
focus?: boolean
preview?: boolean
className?: string
maxLength?: number
onInput?: (message: string) => void
onChange?: (message: string) => void
onFocus?: () => void
onBlur?: () => void
}
const MessageInput: FC<MessageInputProps> = ({
value,
focus = true,
className,
preview,
maxLength = 500,
onInput,
onChange,
onFocus,
onBlur
}) => {
const MessageInput: FC<MessageInputProps> = ({ className }) => {
const send = useRemeshSend()
const messageInputDomain = useRemeshDomain(MessageInputDomain())
const message = useRemeshQuery(messageInputDomain.query.ValueQuery())
const isPreview = useRemeshQuery(messageInputDomain.query.PreviewQuery())
const handleInput = (e: ChangeEvent<HTMLTextAreaElement>) => {
send(messageInputDomain.command.InputCommand(e.target.value))
}
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !(e.shiftKey || e.ctrlKey || e.altKey || e.metaKey)) {
e.preventDefault()
send(messageInputDomain.command.EnterCommand())
}
}
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>
{isPreview ? (
<Markdown className="max-h-32 rounded-lg border border-input bg-gray-50 2xl:max-h-40">{message}</Markdown>
) : (
// Hack: Auto-Growing Textarea
<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"
>
<Textarea
autoFocus={focus}
maxLength={maxLength}
onKeyDown={handleKeyDown}
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"
rows={2}
value={value}
value={message}
placeholder="Type your message here."
onInput={(e: ChangeEvent<HTMLTextAreaElement>) => onInput?.(e.target.value)}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => onChange?.(e.target.value)}
onFocus={onFocus}
onBlur={onBlur}
onInput={handleInput}
/>
</div>
)}
<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>
)

View file

@ -1,19 +1,14 @@
import { useState, type FC } from 'react'
import { type FC } from 'react'
import { Button } from '@/components/ui/Button'
import { SmileIcon, CornerDownLeftIcon, ImageIcon } from 'lucide-react'
import MessageInput from './MessageInput'
const Footer: FC = () => {
const [message, setMessage] = useState('')
const handleInput = (value: string) => {
setMessage(value)
}
const handleSend = () => {}
return (
<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">
<Button variant="ghost" size="icon">
<SmileIcon size={20} />
@ -21,7 +16,7 @@ const Footer: FC = () => {
<Button variant="ghost" size="icon">
<ImageIcon size={20} />
</Button>
<Button size="sm">
<Button size="sm" onClick={handleSend}>
<span className="mr-2">Send</span>
<CornerDownLeftIcon className="text-slate-400" size={12}></CornerDownLeftIcon>
</Button>

View file

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

View file

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

View file

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

View file

@ -15,3 +15,5 @@ export const BREAKPOINTS = {
'2xl': '1536px'
// => @media (min-width: 1536px) { ... }
} 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 createShadowRoot from './createShadowRoot'
import style from './index.css?inline'
import { RemeshRoot } from 'remesh-react'
import { RemeshLogger } from 'remesh-logger'
import { Remesh } from 'remesh'
void (async () => {
const store = Remesh.store({
inspectors: [RemeshLogger()]
})
createShadowRoot(__NAME__, {
style: __DEV__ ? '' : style,
mode: __DEV__ ? 'open' : 'closed'
}).render(
<React.StrictMode>
<RemeshRoot store={store}>
<App />
</RemeshRoot>
</React.StrictMode>
)