perf: add custom scroll bars to scrollable content
This commit is contained in:
parent
c9388c744e
commit
d3fa441846
10 changed files with 861 additions and 588 deletions
82
package.json
82
package.json
|
@ -44,31 +44,31 @@
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/molvqingtai/WebChat#readme",
|
"homepage": "https://github.com/molvqingtai/WebChat#readme",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.3.4",
|
"@hookform/resolvers": "^3.9.0",
|
||||||
"@perfsee/jsonr": "^1.12.2",
|
"@perfsee/jsonr": "^1.13.0",
|
||||||
"@radix-ui/react-avatar": "^1.0.4",
|
"@radix-ui/react-avatar": "^1.1.0",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||||
"@radix-ui/react-hover-card": "^1.0.7",
|
"@radix-ui/react-hover-card": "^1.1.1",
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
"@radix-ui/react-label": "^2.0.2",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
"@radix-ui/react-popover": "^1.0.7",
|
"@radix-ui/react-popover": "^1.1.1",
|
||||||
"@radix-ui/react-radio-group": "^1.1.3",
|
"@radix-ui/react-radio-group": "^1.2.0",
|
||||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"@radix-ui/react-switch": "^1.0.3",
|
"@radix-ui/react-switch": "^1.1.0",
|
||||||
"@tailwindcss/typography": "^0.5.10",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^3.3.1",
|
"date-fns": "^4.1.0",
|
||||||
"idb-keyval": "^6.2.1",
|
"idb-keyval": "^6.2.1",
|
||||||
"lucide-react": "^0.350.0",
|
"lucide-react": "^0.441.0",
|
||||||
"nanoid": "^5.0.6",
|
"nanoid": "^5.0.7",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.51.0",
|
"react-hook-form": "^7.53.0",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"react-use": "^17.5.0",
|
"react-use": "^17.5.1",
|
||||||
"react-virtuoso": "^4.10.4",
|
"react-virtuoso": "^4.10.4",
|
||||||
"remark-breaks": "^4.0.0",
|
"remark-breaks": "^4.0.0",
|
||||||
"remark-gfm": "^4.0.0",
|
"remark-gfm": "^4.0.0",
|
||||||
|
@ -77,47 +77,47 @@
|
||||||
"remesh-react": "^4.1.2",
|
"remesh-react": "^4.1.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"sonner": "^1.5.0",
|
"sonner": "^1.5.0",
|
||||||
"tailwind-merge": "^2.2.1",
|
"tailwind-merge": "^2.5.2",
|
||||||
"trystero": "^0.20.0",
|
"trystero": "^0.20.0",
|
||||||
"type-fest": "^4.11.1",
|
"type-fest": "^4.26.1",
|
||||||
"unstorage": "^1.10.1",
|
"unstorage": "^1.12.0",
|
||||||
"valibot": "^0.42.0",
|
"valibot": "^0.42.0",
|
||||||
"webextension-polyfill": "^0.12.0"
|
"webextension-polyfill": "^0.12.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^19.0.3",
|
"@commitlint/cli": "^19.5.0",
|
||||||
"@commitlint/config-conventional": "^19.0.3",
|
"@commitlint/config-conventional": "^19.5.0",
|
||||||
"@eslint-react/eslint-plugin": "^1.14.1",
|
"@eslint-react/eslint-plugin": "^1.14.1",
|
||||||
"@eslint/js": "^9.10.0",
|
"@eslint/js": "^9.10.0",
|
||||||
"@types/eslint": "^9.6.1",
|
"@types/eslint": "^9.6.1",
|
||||||
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
|
||||||
"@types/eslint__js": "^8.42.3",
|
"@types/eslint__js": "^8.42.3",
|
||||||
"@types/node": "^20.11.25",
|
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
||||||
"@types/react": "^18.2.64",
|
"@types/node": "^22.5.5",
|
||||||
"@types/react-dom": "^18.2.21",
|
"@types/react": "^18.3.7",
|
||||||
"@types/webextension-polyfill": "^0.10.7",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@typescript-eslint/parser": "^8.5.0",
|
"@types/webextension-polyfill": "^0.12.1",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@typescript-eslint/parser": "^8.6.0",
|
||||||
"autoprefixer": "^10.4.18",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"eslint": "^9.10.0",
|
"eslint": "^9.10.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-prettier": "^5.2.1",
|
"eslint-plugin-prettier": "^5.2.1",
|
||||||
"eslint-plugin-tailwindcss": "^3.17.4",
|
"eslint-plugin-tailwindcss": "^3.17.4",
|
||||||
"globals": "^15.9.0",
|
"globals": "^15.9.0",
|
||||||
"husky": "^9.0.11",
|
"husky": "^9.1.6",
|
||||||
"jiti": "^1.21.6",
|
"jiti": "^1.21.6",
|
||||||
"lint-staged": "^15.2.2",
|
"lint-staged": "^15.2.10",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"postcss": "^8.4.35",
|
"postcss": "^8.4.47",
|
||||||
"prettier": "^3.2.5",
|
"prettier": "^3.3.3",
|
||||||
"rimraf": "^5.0.5",
|
"rimraf": "^5.0.10",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.12",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "^5.6.2",
|
"typescript": "^5.6.2",
|
||||||
"typescript-eslint": "^8.5.0",
|
"typescript-eslint": "^8.6.0",
|
||||||
"webext-bridge": "^6.0.1",
|
"webext-bridge": "^6.0.1",
|
||||||
"wxt": "^0.17.7"
|
"wxt": "^0.19.9"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,jsx,ts,tsx}": "eslint --fix --flag unstable_ts_config"
|
"*.{js,jsx,ts,tsx}": "eslint --fix --flag unstable_ts_config"
|
||||||
|
|
1297
pnpm-lock.yaml
1297
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
@ -4,6 +4,7 @@ import React 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 { ScrollArea } from '@/components/ui/ScrollArea'
|
||||||
|
|
||||||
export interface MessageInputProps {
|
export interface MessageInputProps {
|
||||||
value?: string
|
value?: string
|
||||||
|
@ -11,12 +12,13 @@ export interface MessageInputProps {
|
||||||
maxLength?: number
|
maxLength?: number
|
||||||
preview?: boolean
|
preview?: boolean
|
||||||
autoFocus?: boolean
|
autoFocus?: boolean
|
||||||
|
disabled?: boolean
|
||||||
onInput?: (value: string) => void
|
onInput?: (value: string) => void
|
||||||
onEnter?: (value: string) => void
|
onEnter?: (value: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const MessageInput = React.forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
const MessageInput = React.forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
||||||
({ value = '', className, maxLength = 500, onInput, onEnter, preview, autoFocus }, ref) => {
|
({ value = '', className, maxLength = 500, onInput, onEnter, preview, autoFocus, disabled }, ref) => {
|
||||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (e.key === 'Enter' && !(e.shiftKey || e.ctrlKey || e.altKey || e.metaKey)) {
|
if (e.key === 'Enter' && !(e.shiftKey || e.ctrlKey || e.altKey || e.metaKey)) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
@ -30,25 +32,22 @@ const MessageInput = React.forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
||||||
return (
|
return (
|
||||||
<div className={cn('relative', className)}>
|
<div className={cn('relative', className)}>
|
||||||
{preview ? (
|
{preview ? (
|
||||||
<Markdown className="max-h-32 rounded-lg border border-input bg-gray-50 2xl:max-h-40">{value}</Markdown>
|
<Markdown className="max-h-28 rounded-lg border border-input bg-gray-50 2xl:max-h-40">{value}</Markdown>
|
||||||
) : (
|
) : (
|
||||||
// Hack: Auto-Growing Textarea
|
<ScrollArea className="box-border max-h-28 w-full rounded-lg border border-input bg-background ring-offset-background focus-within:ring-1 focus-within:ring-ring 2xl:max-h-40">
|
||||||
<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
|
<Textarea
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
maxLength={maxLength}
|
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 whitespace-pre-wrap break-words rounded-lg bg-gray-50 pb-5 text-sm 2xl:max-h-40"
|
className="box-border resize-none whitespace-pre-wrap break-words border-none bg-gray-50 pb-5 [field-sizing:content] focus:ring-0 focus:ring-offset-0"
|
||||||
rows={2}
|
rows={2}
|
||||||
value={value}
|
value={value}
|
||||||
placeholder="Type your message here."
|
placeholder="Type your message here."
|
||||||
onInput={handleInput}
|
onInput={handleInput}
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</ScrollArea>
|
||||||
)}
|
)}
|
||||||
<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}
|
{value?.length ?? 0}/{maxLength}
|
||||||
|
|
|
@ -8,7 +8,7 @@ export interface MessageListProps {
|
||||||
children?: Array<ReactElement<MessageItemProps>>
|
children?: Array<ReactElement<MessageItemProps>>
|
||||||
}
|
}
|
||||||
const MessageList: FC<MessageListProps> = ({ children }) => {
|
const MessageList: FC<MessageListProps> = ({ children }) => {
|
||||||
const scrollParentRef = useRef<HTMLDivElement | null>(null)
|
const scrollParentRef = useRef<HTMLDivElement>(null)
|
||||||
return (
|
return (
|
||||||
<ScrollArea ref={scrollParentRef}>
|
<ScrollArea ref={scrollParentRef}>
|
||||||
<Virtuoso
|
<Virtuoso
|
||||||
|
|
|
@ -8,7 +8,7 @@ export interface AppContainerProps {
|
||||||
const AppContainer: FC<AppContainerProps> = ({ children }) => {
|
const AppContainer: FC<AppContainerProps> = ({ children }) => {
|
||||||
const { size, ref } = useResizable({
|
const { size, ref } = useResizable({
|
||||||
initSize: Math.max(375, window.innerWidth / 5),
|
initSize: Math.max(375, window.innerWidth / 5),
|
||||||
maxSize: Math.max(750, window.innerWidth / 3),
|
maxSize: Math.min(750, window.innerWidth / 3),
|
||||||
minSize: Math.max(375, window.innerWidth / 5),
|
minSize: Math.max(375, window.innerWidth / 5),
|
||||||
direction: 'left'
|
direction: 'left'
|
||||||
})
|
})
|
||||||
|
|
|
@ -26,18 +26,6 @@ const Main: FC = () => {
|
||||||
send(roomDomain.command.SendHateMessageCommand(messageId))
|
send(roomDomain.command.SendHateMessageCommand(messageId))
|
||||||
}
|
}
|
||||||
|
|
||||||
// useEffect(() => {
|
|
||||||
// const lastMessageRef = messageListRef.current?.querySelector('[data-index]:last-child')
|
|
||||||
// const timerId = setTimeout(() => {
|
|
||||||
// requestAnimationFrame(() => {
|
|
||||||
// lastMessageRef?.scrollIntoView({ behavior: isUpdate.current ? 'smooth' : 'instant', block: 'end' })
|
|
||||||
// isUpdate.current = true
|
|
||||||
// })
|
|
||||||
// }, 0)
|
|
||||||
|
|
||||||
// return () => clearTimeout(timerId)
|
|
||||||
// }, [messageList.length])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageList>
|
<MessageList>
|
||||||
{messageList.map((message, index) => (
|
{messageList.map((message, index) => (
|
||||||
|
|
|
@ -81,4 +81,11 @@
|
||||||
all: initial;
|
all: initial;
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Fix: scroll area dispay: table
|
||||||
|
* @see https://github.com/radix-ui/primitives/issues/3129
|
||||||
|
*/
|
||||||
|
[data-radix-scroll-area-viewport] > div {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import remarkBreaks from 'remark-breaks'
|
import remarkBreaks from 'remark-breaks'
|
||||||
import { cn } from '@/utils'
|
import { cn } from '@/utils'
|
||||||
|
import { ScrollArea, ScrollBar } from './ScrollArea'
|
||||||
|
|
||||||
export interface MarkdownProps {
|
export interface MarkdownProps {
|
||||||
children?: string
|
children?: string
|
||||||
|
@ -14,11 +15,11 @@ const Markdown: FC<MarkdownProps> = ({ children = '', className }) => {
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
components={{
|
components={{
|
||||||
h1: ({ className, ...props }) => (
|
h1: ({ className, ...props }) => (
|
||||||
<h1 className={cn('mb-2 mt-0 font-semibold text-2xl', className)} {...props} />
|
<h1 className={cn('my-2 mt-0 font-semibold text-2xl', className)} {...props} />
|
||||||
),
|
),
|
||||||
h2: ({ className, ...props }) => <h2 className={cn('mb-2 mt-0 font-semibold', className)} {...props} />,
|
h2: ({ className, ...props }) => <h2 className={cn('mb-2 mt-0 font-semibold', className)} {...props} />,
|
||||||
img: ({ className, alt, ...props }) => (
|
img: ({ className, alt, ...props }) => (
|
||||||
<img className={cn('my-2 max-w-[50%]', className)} alt={alt} {...props} />
|
<img className={cn('my-2 max-w-[100%] rounded', className)} alt={alt} {...props} />
|
||||||
),
|
),
|
||||||
ul: ({ className, ...props }) => {
|
ul: ({ className, ...props }) => {
|
||||||
Reflect.deleteProperty(props, 'ordered')
|
Reflect.deleteProperty(props, 'ordered')
|
||||||
|
@ -26,18 +27,17 @@ const Markdown: FC<MarkdownProps> = ({ children = '', className }) => {
|
||||||
},
|
},
|
||||||
input: ({ className, ...props }) => <input className={cn('my-0', className)} {...props} />,
|
input: ({ className, ...props }) => <input className={cn('my-0', className)} {...props} />,
|
||||||
table: ({ className, ...props }) => (
|
table: ({ className, ...props }) => (
|
||||||
<div className="my-4 w-full overflow-y-auto">
|
<div className="my-2 w-full">
|
||||||
|
<ScrollArea>
|
||||||
<table className={cn('my-0 w-full rounded-md', className)} {...props} />
|
<table className={cn('my-0 w-full rounded-md', className)} {...props} />
|
||||||
|
<ScrollBar orientation="horizontal" />
|
||||||
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
tr: ({ className, ...props }) => {
|
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} />
|
return <tr className={cn('m-0 border-t p-0 even:bg-muted', className)} {...props} />
|
||||||
},
|
},
|
||||||
th: ({ className, ...props }) => {
|
th: ({ className, ...props }) => {
|
||||||
// fix: spell it as lowercase `isheader` warning
|
|
||||||
Reflect.deleteProperty(props, 'isHeader')
|
|
||||||
return (
|
return (
|
||||||
<th
|
<th
|
||||||
className={cn(
|
className={cn(
|
||||||
|
@ -58,7 +58,14 @@ const Markdown: FC<MarkdownProps> = ({ children = '', className }) => {
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
|
pre: ({ className, ...props }) => <pre className={cn('my-2', className)} {...props} />,
|
||||||
|
code: ({ className, ...props }) => (
|
||||||
|
<ScrollArea>
|
||||||
|
<code className={cn('text-sm', className)} {...props}></code>
|
||||||
|
<ScrollBar orientation="horizontal" />
|
||||||
|
</ScrollArea>
|
||||||
|
)
|
||||||
}}
|
}}
|
||||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||||
className={cn(className, 'prose prose-sm prose-slate break-words')}
|
className={cn(className, 'prose prose-sm prose-slate break-words')}
|
||||||
|
|
|
@ -7,7 +7,7 @@ const ScrollArea = React.forwardRef<
|
||||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||||
>(({ className, children, ...props }, ref) => (
|
>(({ className, children, ...props }, ref) => (
|
||||||
<ScrollAreaPrimitive.Root className={cn('relative overflow-hidden', className)} {...props}>
|
<ScrollAreaPrimitive.Root className={cn('relative grid grid-rows-[1fr] overflow-hidden', className)} {...props}>
|
||||||
<ScrollAreaPrimitive.Viewport ref={ref} className="size-full overscroll-none rounded-[inherit]">
|
<ScrollAreaPrimitive.Viewport ref={ref} className="size-full overscroll-none rounded-[inherit]">
|
||||||
{children}
|
{children}
|
||||||
</ScrollAreaPrimitive.Viewport>
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
|
|
@ -52,6 +52,9 @@ export default {
|
||||||
minWidth: {
|
minWidth: {
|
||||||
screen: '100vw'
|
screen: '100vw'
|
||||||
},
|
},
|
||||||
|
maxWidth: {
|
||||||
|
layer: 'revert-layer'
|
||||||
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: 'var(--radius)',
|
lg: 'var(--radius)',
|
||||||
md: 'calc(var(--radius) - 2px)',
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
|
Loading…
Reference in a new issue