feat: app button support drag

This commit is contained in:
molvqingtai 2024-10-19 01:18:07 +08:00
parent f7cdf212bc
commit 4eba638a36
18 changed files with 277 additions and 125 deletions

View file

@ -1,4 +1,4 @@
// import type { Linter } from 'eslint'
import type { Linter } from 'eslint'
import globals from 'globals'
import pluginJs from '@eslint/js'
import tseslint from 'typescript-eslint'
@ -34,8 +34,9 @@ export default [
'@typescript-eslint/no-unused-expressions': 'off',
'@eslint-react/no-array-index-key': 'off',
'@eslint-react/hooks-extra/no-redundant-custom-hook': 'off',
'@eslint-react/dom/no-missing-button-type': 'off'
'@eslint-react/dom/no-missing-button-type': 'off',
'@eslint-react/hooks-extra/prefer-use-state-lazy-initialization': 'off'
}
}
]
// satisfies Linter.Config[]
// satisfies Linter.Config[]

View file

@ -101,8 +101,8 @@
"@semantic-release/exec": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@types/eslint": "^9.6.1",
"@types/eslint__js": "^8.42.3",
"@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/eslint__js": "^8.42.3",
"@types/node": "^22.7.5",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",

View file

@ -2,7 +2,7 @@ import Header from '@/app/content/views/Header'
import Footer from '@/app/content/views/Footer'
import Main from '@/app/content/views/Main'
import AppButton from '@/app/content/views/AppButton'
import AppContainer from '@/app/content/views/AppContainer'
import AppMain from '@/app/content/views/AppMain'
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
import RoomDomain from '@/domain/Room'
import UserInfoDomain from '@/domain/UserInfo'
@ -10,6 +10,7 @@ import Setup from '@/app/content/views/Setup'
import MessageListDomain from '@/domain/MessageList'
import { useEffect, useRef } from 'react'
import { Toaster } from 'sonner'
import { AnimatePresence, motion } from 'framer-motion'
import DanmakuContainer from './components/DanmakuContainer'
import DanmakuDomain from '@/domain/Danmaku'
@ -31,8 +32,8 @@ export default function App() {
const danmakuDomain = useRemeshDomain(DanmakuDomain())
const danmakuIsEnabled = useRemeshQuery(danmakuDomain.query.IsEnabledQuery())
const userInfoSetFinished = useRemeshQuery(userInfoDomain.query.UserInfoSetIsFinishedQuery())
const userInfoLoadFinished = useRemeshQuery(userInfoDomain.query.UserInfoLoadIsFinishedQuery())
const messageListLoadFinished = useRemeshQuery(messageListDomain.query.LoadIsFinishedQuery())
const userInfoLoadFinished = useRemeshQuery(userInfoDomain.query.UserInfoLoadIsFinishedQuery())
const notUserInfo = userInfoLoadFinished && !userInfoSetFinished
@ -58,14 +59,21 @@ export default function App() {
return (
<>
<AppContainer>
<AppMain>
<Header />
<Main />
<Footer />
{notUserInfo && <Setup />}
<AnimatePresence>
{notUserInfo && (
<motion.div initial={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.3 }}>
<Setup></Setup>
</motion.div>
)}
</AnimatePresence>
<Toaster richColors offset="70px" visibleToasts={1} position="top-center"></Toaster>
</AppContainer>
</AppMain>
<AppButton></AppButton>
<DanmakuContainer ref={danmakuContainerRef} />
</>
)

View file

@ -1,6 +1,5 @@
import { type FC } from 'react'
import { FrownIcon, ThumbsUpIcon } from 'lucide-react'
import { Badge } from '@/components/ui/Badge'
import LikeButton from './LikeButton'
import FormatDate from './FormatDate'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/Avatar'

View file

@ -14,6 +14,7 @@ const MessageList: FC<MessageListProps> = ({ children }) => {
return (
<ScrollArea ref={setScrollParentRef}>
<Virtuoso
defaultItemHeight={108}
followOutput={(isAtBottom: boolean) => (isAtBottom ? 'smooth' : 'auto')}
initialTopMostItemIndex={{ index: 'LAST', align: 'end' }}
data={children}

View file

@ -1,8 +1,7 @@
import { type FC, useState, type MouseEvent, useRef } from 'react'
import { SettingsIcon, MoonIcon, SunIcon } from 'lucide-react'
import { type FC, useState, type MouseEvent, useRef, useEffect, useLayoutEffect } from 'react'
import { SettingsIcon, MoonIcon, SunIcon, HandIcon } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
import { browser } from 'wxt/browser'
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
import { Button } from '@/components/ui/Button'
import { EVENT } from '@/constants/event'
@ -20,6 +19,8 @@ import LogoIcon6 from '@/assets/images/logo-6.svg'
import AppStatusDomain from '@/domain/AppStatus'
import { getDay } from 'date-fns'
import { messenger } from '@/messenger'
import useDarg from '@/hooks/useDarg'
import { useWindowSize } from 'react-use'
const AppButton: FC = () => {
const send = useRemeshSend()
@ -29,6 +30,9 @@ const AppButton: FC = () => {
const userInfoDomain = useRemeshDomain(UserInfoDomain())
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
const toastDomain = useRemeshDomain(ToastDomain())
const appPosition = useRemeshQuery(appStatusDomain.query.PositionQuery())
const appStatusLoadIsFinished = useRemeshQuery(appStatusDomain.query.StatusLoadIsFinishedQuery())
const DayLogo = [LogoIcon0, LogoIcon1, LogoIcon2, LogoIcon3, LogoIcon4, LogoIcon5, LogoIcon6][getDay(Date())]
const isDarkMode =
@ -38,6 +42,21 @@ const AppButton: FC = () => {
const menuRef = useRef<HTMLDivElement>(null)
const { width, height } = useWindowSize()
const { x, y, ref } = useDarg({
initX: appPosition.x,
initY: appPosition.y,
minX: 44,
maxX: width - 44,
maxY: height - 22,
minY: height / 2
})
useLayoutEffect(() => {
appStatusLoadIsFinished && send(appStatusDomain.command.UpdatePositionCommand({ x, y }))
}, [x, y])
useClickAway(menuRef, () => {
setMenuOpen(false)
}, ['click'])
@ -65,7 +84,15 @@ const AppButton: FC = () => {
}
return (
<div ref={menuRef} className="fixed bottom-5 right-5 z-infinity grid select-none justify-center gap-y-3">
<div
ref={menuRef}
className="fixed bottom-5 right-5 z-infinity grid w-min select-none justify-center gap-y-3"
style={{
left: `calc(${appPosition.x}px)`,
bottom: `calc(100vh - ${appPosition.y}px)`,
transform: 'translateX(-50%)'
}}
>
<AnimatePresence>
{menuOpen && (
<motion.div
@ -92,13 +119,12 @@ const AppButton: FC = () => {
</div>
</Button>
<Button
onClick={handleOpenOptionsPage}
variant="outline"
className="pointer-events-auto size-10 rounded-full p-0 shadow"
>
<Button onClick={handleOpenOptionsPage} variant="outline" className="size-10 rounded-full p-0 shadow">
<SettingsIcon size={20} />
</Button>
<Button ref={ref} variant="outline" className="size-10 cursor-grab rounded-full p-0 shadow">
<HandIcon size={20} />
</Button>
</motion.div>
)}
</AnimatePresence>

View file

@ -1,48 +0,0 @@
import { type ReactNode, type FC } from 'react'
import useResizable from '@/hooks/useResizable'
import { motion, AnimatePresence } from 'framer-motion'
import AppStatusDomain from '@/domain/AppStatus'
import { useRemeshDomain, useRemeshQuery } from 'remesh-react'
export interface AppContainerProps {
children?: ReactNode
}
const AppContainer: FC<AppContainerProps> = ({ children }) => {
const appStatusDomain = useRemeshDomain(AppStatusDomain())
const appOpenStatus = useRemeshQuery(appStatusDomain.query.OpenQuery())
const { size, ref } = useResizable({
initSize: Math.max(375, window.innerWidth / 6),
maxSize: Math.min(750, window.innerWidth / 3),
minSize: Math.max(375, window.innerWidth / 6),
direction: 'left'
})
return (
<AnimatePresence>
{appOpenStatus && (
<motion.div
initial={{ opacity: 0, y: 10, x: 10 }}
animate={{ opacity: 1, y: 0, x: 0 }}
exit={{ opacity: 0, y: 10, x: 10 }}
transition={{ duration: 0.3 }}
style={{
width: `${size}px`
}}
className="fixed bottom-10 right-10 z-infinity box-border grid h-screen max-h-[min(calc(100vh_-60px),_1200px)] grid-flow-col grid-rows-[auto_1fr_auto] rounded-xl bg-slate-50 font-sans shadow-2xl"
>
{children}
<div
ref={ref}
className="absolute inset-y-3 -left-0.5 z-20 w-1 cursor-ew-resize rounded-xl bg-slate-100 opacity-0 shadow transition-opacity duration-200 ease-in hover:opacity-100"
></div>
</motion.div>
)}
</AnimatePresence>
)
}
AppContainer.displayName = 'AppContainer'
export default AppContainer

View file

@ -0,0 +1,67 @@
import { type ReactNode, type FC, useState } from 'react'
import useResizable from '@/hooks/useResizable'
import { motion, AnimatePresence } from 'framer-motion'
import AppStatusDomain from '@/domain/AppStatus'
import { useRemeshDomain, useRemeshQuery } from 'remesh-react'
import { cn } from '@/utils'
import { useWindowSize } from 'react-use'
export interface AppMainProps {
children?: ReactNode
}
const AppMain: FC<AppMainProps> = ({ children }) => {
const appStatusDomain = useRemeshDomain(AppStatusDomain())
const appOpenStatus = useRemeshQuery(appStatusDomain.query.OpenQuery())
const { x, y } = useRemeshQuery(appStatusDomain.query.PositionQuery())
const { width } = useWindowSize()
const isOnRightSide = x >= width / 2 + 44
const { size, ref } = useResizable({
initSize: Math.max(375, width / 6),
maxSize: Math.max(Math.min(750, width / 3), 375),
minSize: Math.max(375, width / 6),
direction: isOnRightSide ? 'left' : 'right'
})
const [isAnimationComplete, setAnimationComplete] = useState(false)
return (
<AnimatePresence>
{appOpenStatus && (
<motion.div
initial={{ opacity: 0, y: 10, x: isOnRightSide ? '-100%' : '0' }}
animate={{ opacity: 1, y: 0, x: isOnRightSide ? '-100%' : '0' }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.3, ease: 'linear' }}
onAnimationEnd={() => setAnimationComplete(true)}
onAnimationStart={() => setAnimationComplete(false)}
style={{
width: `${size}px`,
left: `${x}px`,
bottom: `calc(100vh - ${y}px + 22px)`
}}
className={cn(
'fixed inset-y-10 right-10 z-infinity mb-0 mt-auto box-border grid max-h-[min(calc(100vh_-60px),_1000px)] grid-flow-col grid-rows-[auto_1fr_auto] rounded-xl bg-slate-50 font-sans shadow-2xl',
{ 'transition-transform': isAnimationComplete }
)}
>
{children}
<div
ref={ref}
className={cn(
'absolute inset-y-3 z-20 w-1 cursor-ew-resize rounded-xl bg-slate-100 opacity-0 shadow transition-opacity duration-200 ease-in hover:opacity-100',
isOnRightSide ? '-left-0.5' : '-right-0.5'
)}
></div>
</motion.div>
)}
</AnimatePresence>
)
}
AppMain.displayName = 'AppMain'
export default AppMain

View file

@ -7,7 +7,6 @@ import PromptItem from '../../components/PromptItem'
import UserInfoDomain from '@/domain/UserInfo'
import RoomDomain, { MessageType } from '@/domain/Room'
import MessageListDomain from '@/domain/MessageList'
import BlurFade from '@/components/magicui/BlurFade'
const Main: FC = () => {
const send = useRemeshSend()
@ -39,24 +38,21 @@ const Main: FC = () => {
<MessageList>
{messageList.map((message, index) =>
message.type === MessageType.Normal ? (
<BlurFade key={message.id} duration={0.1} yOffset={0}>
<MessageItem
key={message.id}
data={message}
like={message.like}
hate={message.hate}
onLikeChange={() => handleLikeChange(message.id)}
onHateChange={() => handleHateChange(message.id)}
></MessageItem>
</BlurFade>
<MessageItem
key={message.id}
data={message}
like={message.like}
hate={message.hate}
onLikeChange={() => handleLikeChange(message.id)}
onHateChange={() => handleHateChange(message.id)}
className="duration-300 animate-in fade-in-0"
></MessageItem>
) : (
<BlurFade key={message.id} duration={0.1} yOffset={0}>
<PromptItem
key={message.id}
data={message}
className={`${index === 0 ? 'pt-4' : ''} ${index === messageList.length - 1 ? 'pb-4' : ''}`}
></PromptItem>
</BlurFade>
<PromptItem
key={message.id}
data={message}
className={`${index === 0 ? 'pt-4' : ''} ${index === messageList.length - 1 ? 'pb-4' : ''}`}
></PromptItem>
)
)}
</MessageList>

View file

@ -68,6 +68,7 @@ const Setup: FC = () => {
const messageListDomain = useRemeshDomain(MessageListDomain())
const [userInfo, setUserInfo] = useState<UserInfo>()
const handleSetup = () => {
send(userInfoDomain.command.UpdateUserInfoCommand(userInfo!))
send(messageListDomain.command.ClearListCommand())

View file

@ -67,7 +67,7 @@ const Markdown: FC<MarkdownProps> = ({ children = '', className }) => {
input: ({ className, ...props }) => <input className={cn('my-0', className)} {...props} />,
table: ({ className, ...props }) => (
<div className="my-2 w-full">
<ScrollArea>
<ScrollArea scrollLock={false}>
<table className={cn('my-0 w-full rounded-md', className)} {...props} />
<ScrollBar orientation="horizontal" />
</ScrollArea>
@ -106,7 +106,7 @@ const Markdown: FC<MarkdownProps> = ({ children = '', className }) => {
*
*/
code: ({ className, ...props }) => (
<ScrollArea>
<ScrollArea className="overscroll-y-auto" scrollLock={false}>
<code className={cn('text-sm', className)} {...props}></code>
<ScrollBar orientation="horizontal" />
</ScrollArea>

View file

@ -5,10 +5,13 @@ import { cn } from '@/utils/index'
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & { scrollLock?: boolean }
>(({ className, children, scrollLock = true, ...props }, ref) => (
<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={cn('size-full rounded-[inherit]', scrollLock ? 'overscroll-none' : 'overscroll-auto')}
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />

View file

@ -9,11 +9,13 @@ import { map } from 'rxjs'
export interface AppStatus {
open: boolean
unread: number
position: { x: number; y: number }
}
export const defaultStatusState = {
open: false,
unread: 0
unread: 0,
position: { x: window.innerWidth - 44, y: window.innerHeight - 22 }
}
const AppStatusDomain = Remesh.domain({
@ -32,8 +34,8 @@ const AppStatusDomain = Remesh.domain({
const StatusLoadIsFinishedQuery = domain.query({
name: 'AppStatus.StatusLoadIsFinishedQuery',
impl: () => {
return StatusLoadModule.query.IsFinishedQuery()
impl: ({ get }) => {
return get(StatusLoadModule.query.IsFinishedQuery())
}
})
@ -56,6 +58,13 @@ const AppStatusDomain = Remesh.domain({
}
})
const PositionQuery = domain.query({
name: 'AppStatus.PositionQuery',
impl: ({ get }) => {
return get(StatusState()).position
}
})
const HasUnreadQuery = domain.query({
name: 'AppStatus.HasUnreadQuery',
impl: ({ get }) => {
@ -68,6 +77,7 @@ const AppStatusDomain = Remesh.domain({
impl: ({ get }, value: boolean) => {
const status = get(StatusState())
return UpdateStatusCommand({
...status,
unread: value ? 0 : status.unread,
open: value
})
@ -85,6 +95,17 @@ const AppStatusDomain = Remesh.domain({
}
})
const UpdatePositionCommand = domain.command({
name: 'AppStatus.UpdatePositionCommand',
impl: ({ get }, value: { x: number; y: number }) => {
const status = get(StatusState())
return UpdateStatusCommand({
...status,
position: value
})
}
})
const UpdateStatusCommand = domain.command({
name: 'AppStatus.UpdateStatusCommand',
impl: (_, value: AppStatus) => {
@ -128,11 +149,13 @@ const AppStatusDomain = Remesh.domain({
OpenQuery,
UnreadQuery,
HasUnreadQuery,
PositionQuery,
StatusLoadIsFinishedQuery
},
command: {
UpdateOpenCommand,
UpdateUnreadCommand
UpdateUnreadCommand,
UpdatePositionCommand
},
event: {
SyncToStorageEvent

View file

@ -2,7 +2,7 @@ import { Remesh } from 'remesh'
import { DanmakuExtern } from './externs/Danmaku'
import RoomDomain, { TextMessage } from './Room'
import UserInfoDomain from './UserInfo'
import { map, merge, of } from 'rxjs'
import { map, merge } from 'rxjs'
const DanmakuDomain = Remesh.domain({
name: 'DanmakuDomain',

View file

@ -4,7 +4,7 @@ import localStorageDriver from 'unstorage/drivers/localstorage'
import { LocalStorageExtern, IndexDBStorageExtern, BrowserSyncStorageExtern } from '@/domain/externs/Storage'
import { STORAGE_NAME } from '@/constants/config'
import { webExtensionDriver } from '@/utils/webExtensionDriver'
import { browser } from 'wxt/browser'
import { Storage } from '@/domain/externs/Storage'
import { EVENT } from '@/constants/event'
@ -62,23 +62,3 @@ export const BrowserSyncStorageImpl = BrowserSyncStorageExtern.impl({
watch: browserSyncStorage.watch as Storage['watch'],
unwatch: browserSyncStorage.unwatch
})
// export const BrowserSyncStorageImpl = BrowserSyncStorageExtern.impl({
// name: STORAGE_NAME,
// get: async (key: string) => {
// const res = await browser.storage.sync.get(key)
// return res[key] ?? null
// },
// set: async (key, value) => {
// await browser.storage.sync.set({ [key]: value ?? null })
// },
// remove: browserSyncStorage.removeItem,
// clear: browserSyncStorage.clear,
// watch: async (callback) => {
// browser.storage.sync.onChanged.addListener(callback)
// return async () => {
// return browser.storage.sync.onChanged.removeListener(callback)
// }
// },
// unwatch: browserSyncStorage.unwatch
// })

View file

@ -1,5 +1,5 @@
import { type RemeshEvent, type RemeshAction, type RemeshDomainContext, type RemeshExtern } from 'remesh'
import { defer, from, map, Observable, switchMap } from 'rxjs'
import { from, map, Observable, switchMap } from 'rxjs'
import { Storage, StorageValue } from '@/domain/externs/Storage'

91
src/hooks/useDarg.ts Normal file
View file

@ -0,0 +1,91 @@
import { clamp, isInRange } from '@/utils'
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
export interface DargOptions {
initX: number
initY: number
maxX: number
minX: number
maxY: number
minY: number
}
const useDarg = (options: DargOptions) => {
const { initX, initY, maxX = 0, minX = 0, maxY = 0, minY = 0 } = options
const mousePosition = useRef({ x: 0, y: 0 })
const [position, setPosition] = useState({ x: clamp(initX, minX, maxX), y: clamp(initY, minY, maxY) })
useLayoutEffect(() => {
setPosition({ x: clamp(initX, minX, maxX), y: clamp(initY, minY, maxY) })
}, [initX, initY, maxX, minX, maxY, minY])
const isMove = useRef(false)
const handleMove = useCallback(
(e: MouseEvent) => {
if (isMove.current) {
const { clientX, clientY } = e
const delta = {
x: position.x + clientX - mousePosition.current.x,
y: position.y + clientY - mousePosition.current.y
}
const hasChanged = delta.x !== position.x || delta.y !== position.y
if (isInRange(delta.x, minX, maxX)) {
mousePosition.current.x = clientX
}
if (isInRange(delta.y, minY, maxY)) {
mousePosition.current.y = clientY
}
if (hasChanged) {
setPosition(() => {
const x = clamp(delta.x, minX, maxX)
const y = clamp(delta.y, minY, maxY)
return { x, y }
})
}
}
},
[minX, maxX, minY, maxY, position]
)
const handleEnd = useCallback(() => {
isMove.current = false
document.documentElement.style.cursor = ''
document.documentElement.style.userSelect = ''
}, [])
const handleStart = useCallback((e: MouseEvent) => {
const { clientX, clientY } = e
mousePosition.current = { x: clientX, y: clientY }
isMove.current = true
document.documentElement.style.userSelect = 'none'
document.documentElement.style.cursor = 'grab'
}, [])
const handleRef = useRef<HTMLElement | null>(null)
const setHandleRef = useCallback(
(node: HTMLElement | null) => {
if (handleRef.current) {
handleRef.current.removeEventListener('mousedown', handleStart)
document.removeEventListener('mouseup', handleEnd)
document.removeEventListener('mousemove', handleMove)
}
if (node) {
node.addEventListener('mousedown', handleStart)
document.addEventListener('mouseup', handleEnd)
document.addEventListener('mousemove', handleMove)
}
handleRef.current = node
},
[handleEnd, handleMove, handleStart]
)
return { ref: setHandleRef, ...position }
}
export default useDarg

View file

@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from 'react'
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
import { clamp, isInRange } from '@/utils'
export interface ResizableOptions {
@ -11,7 +11,11 @@ export interface ResizableOptions {
const useResizable = (options: ResizableOptions) => {
const { minSize, maxSize, initSize = 0, direction } = options
const [size, setSize] = useState(initSize)
const [size, setSize] = useState(clamp(initSize, minSize, maxSize))
useLayoutEffect(() => {
setSize(clamp(initSize, minSize, maxSize))
}, [initSize, minSize, maxSize])
const position = useRef(0)
@ -67,13 +71,13 @@ const useResizable = (options: ResizableOptions) => {
[isHorizontal]
)
const ref = useRef<HTMLElement | null>(null)
const handlerRef = useRef<HTMLElement | null>(null)
// Watch ref: https://medium.com/@teh_builder/ref-objects-inside-useeffect-hooks-eb7c15198780
const setRef = useCallback(
const setHandleRef = useCallback(
(node: HTMLElement | null) => {
if (ref.current) {
ref.current.removeEventListener('mousedown', handleStart)
if (handlerRef.current) {
handlerRef.current.removeEventListener('mousedown', handleStart)
document.removeEventListener('mouseup', handleEnd)
document.removeEventListener('mousemove', handleMove)
}
@ -82,12 +86,12 @@ const useResizable = (options: ResizableOptions) => {
document.addEventListener('mouseup', handleEnd)
document.addEventListener('mousemove', handleMove)
}
ref.current = node
handlerRef.current = node
},
[handleEnd, handleMove, handleStart]
)
return { size, ref: setRef }
return { size, ref: setHandleRef }
}
export default useResizable