feat(web): add scroll fade hint effect to chat message list
Use CSS mask-image gradients to hint at scrollable overflow in the chat area. Adds useScrollFade hook that dynamically applies top/bottom fade based on scroll position via scroll events and ResizeObserver. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
047de2b431
commit
7d326695c1
9 changed files with 114 additions and 6 deletions
|
|
@ -1,15 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { SidebarTrigger } from "@multica/ui/components/ui/sidebar";
|
||||
import { ChatInput } from "@multica/ui/components/chat-input";
|
||||
import { MemoizedMarkdown } from "@multica/ui/components/markdown";
|
||||
import { useDeviceStore } from "@multica/store";
|
||||
import { useMessages } from "../hooks/use-messages";
|
||||
import { useScrollFade } from "../hooks/use-scroll-fade";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
|
||||
export function Chat() {
|
||||
const deviceId = useDeviceStore((s) => s.deviceId);
|
||||
const messages = useMessages();
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
const fadeStyle = useScrollFade(mainRef);
|
||||
|
||||
return (
|
||||
<div className="h-dvh flex flex-col overflow-hidden w-full">
|
||||
|
|
@ -20,7 +24,7 @@ export function Chat() {
|
|||
</span>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-y-auto min-h-0">
|
||||
<main ref={mainRef} className="flex-1 overflow-y-auto min-h-0" style={fadeStyle}>
|
||||
<div className="px-4 py-6 space-y-6 max-w-4xl mx-auto">
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
|
|
@ -47,7 +51,7 @@ export function Chat() {
|
|||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="w-full px-4 max-w-4xl mx-auto">
|
||||
<footer className="w-full p-2 pt-1 max-w-4xl mx-auto">
|
||||
<ChatInput />
|
||||
</footer>
|
||||
</div>
|
||||
|
|
|
|||
67
apps/web/app/hooks/use-scroll-fade.ts
Normal file
67
apps/web/app/hooks/use-scroll-fade.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { type RefObject, type CSSProperties, useEffect, useState, useCallback } from "react";
|
||||
|
||||
/**
|
||||
* Returns a dynamic maskImage style based on scroll position.
|
||||
* - At top → fade bottom only
|
||||
* - At bottom → fade top only
|
||||
* - In middle → fade both
|
||||
* - No overflow → undefined (no mask)
|
||||
*/
|
||||
export function useScrollFade(
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
fadeSize = 32
|
||||
): CSSProperties | undefined {
|
||||
const [fade, setFade] = useState<"none" | "top" | "bottom" | "both">("none");
|
||||
|
||||
const update = useCallback(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = el;
|
||||
const scrollable = scrollHeight - clientHeight;
|
||||
|
||||
if (scrollable <= 0) {
|
||||
setFade("none");
|
||||
return;
|
||||
}
|
||||
|
||||
const atTop = scrollTop <= 1;
|
||||
const atBottom = scrollTop >= scrollable - 1;
|
||||
|
||||
if (atTop && atBottom) setFade("none");
|
||||
else if (atTop) setFade("bottom");
|
||||
else if (atBottom) setFade("top");
|
||||
else setFade("both");
|
||||
}, [ref]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
update();
|
||||
|
||||
el.addEventListener("scroll", update, { passive: true });
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
|
||||
return () => {
|
||||
el.removeEventListener("scroll", update);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [ref, update]);
|
||||
|
||||
if (fade === "none") return undefined;
|
||||
|
||||
const top = fade === "top" || fade === "both" ? `transparent 0%, black ${fadeSize}px` : "black 0%";
|
||||
const bottom =
|
||||
fade === "bottom" || fade === "both"
|
||||
? `black calc(100% - ${fadeSize}px), transparent 100%`
|
||||
: "black 100%";
|
||||
|
||||
const gradient = `linear-gradient(to bottom, ${top}, ${bottom})`;
|
||||
|
||||
return {
|
||||
maskImage: gradient,
|
||||
WebkitMaskImage: gradient,
|
||||
};
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@multica/store": "workspace:*",
|
||||
"@multica/ui": "workspace:*",
|
||||
"@hugeicons/core-free-icons": "^3.1.1",
|
||||
"@hugeicons/react": "^1.1.4",
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"uuid": "^13.0.0",
|
||||
"zustand": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
16
packages/store/src/device.ts
Normal file
16
packages/store/src/device.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import { v7 as uuidv7 } from 'uuid'
|
||||
|
||||
interface DeviceState {
|
||||
deviceId: string
|
||||
}
|
||||
|
||||
export const useDeviceStore = create<DeviceState>()(
|
||||
persist(
|
||||
() => ({
|
||||
deviceId: uuidv7(),
|
||||
}),
|
||||
{ name: 'multica-device' }
|
||||
)
|
||||
)
|
||||
|
|
@ -1 +1,2 @@
|
|||
export { useCounterStore } from './counter'
|
||||
export { useDeviceStore } from './device'
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export function ChatInput() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-xl p-3 pt-1 border border-border">
|
||||
<div className="bg-card rounded-xl p-3 border border-border">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={2}
|
||||
|
|
|
|||
|
|
@ -137,6 +137,17 @@
|
|||
color: var(--shiki-dark) !important;
|
||||
}
|
||||
|
||||
/* Scroll fade hint utilities — mask content edges to hint at scrollable overflow */
|
||||
.mask-fade-y {
|
||||
mask-image: linear-gradient(to bottom, transparent 0%, black 32px, black calc(100% - 32px), transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 32px, black calc(100% - 32px), transparent 100%);
|
||||
}
|
||||
|
||||
.mask-fade-bottom {
|
||||
mask-image: linear-gradient(to bottom, black 0%, black calc(100% - 32px), transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, black 0%, black calc(100% - 32px), transparent 100%);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
|
|
|
|||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
|
|
@ -208,6 +208,9 @@ importers:
|
|||
'@hugeicons/react':
|
||||
specifier: ^1.1.4
|
||||
version: 1.1.4(react@19.2.3)
|
||||
'@multica/store':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/store
|
||||
'@multica/ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/ui
|
||||
|
|
@ -258,6 +261,9 @@ importers:
|
|||
|
||||
packages/store:
|
||||
dependencies:
|
||||
uuid:
|
||||
specifier: ^13.0.0
|
||||
version: 13.0.0
|
||||
zustand:
|
||||
specifier: 'catalog:'
|
||||
version: 5.0.10(@types/react@19.2.10)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3))
|
||||
|
|
@ -9975,7 +9981,7 @@ snapshots:
|
|||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1))
|
||||
|
|
@ -10008,7 +10014,7 @@ snapshots:
|
|||
tinyglobby: 0.2.15
|
||||
unrs-resolver: 1.11.1
|
||||
optionalDependencies:
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
|
@ -10023,7 +10029,7 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)):
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@rtsao/scc': 1.1.0
|
||||
array-includes: 3.1.9
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue