feat: app button support drag
This commit is contained in:
parent
f7cdf212bc
commit
4eba638a36
18 changed files with 277 additions and 125 deletions
|
@ -1,4 +1,4 @@
|
||||||
// import type { Linter } from 'eslint'
|
import type { Linter } from 'eslint'
|
||||||
import globals from 'globals'
|
import globals from 'globals'
|
||||||
import pluginJs from '@eslint/js'
|
import pluginJs from '@eslint/js'
|
||||||
import tseslint from 'typescript-eslint'
|
import tseslint from 'typescript-eslint'
|
||||||
|
@ -34,7 +34,8 @@ export default [
|
||||||
'@typescript-eslint/no-unused-expressions': 'off',
|
'@typescript-eslint/no-unused-expressions': 'off',
|
||||||
'@eslint-react/no-array-index-key': 'off',
|
'@eslint-react/no-array-index-key': 'off',
|
||||||
'@eslint-react/hooks-extra/no-redundant-custom-hook': '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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
@ -101,8 +101,8 @@
|
||||||
"@semantic-release/exec": "^6.0.3",
|
"@semantic-release/exec": "^6.0.3",
|
||||||
"@semantic-release/git": "^10.0.1",
|
"@semantic-release/git": "^10.0.1",
|
||||||
"@types/eslint": "^9.6.1",
|
"@types/eslint": "^9.6.1",
|
||||||
"@types/eslint__js": "^8.42.3",
|
|
||||||
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
||||||
|
"@types/eslint__js": "^8.42.3",
|
||||||
"@types/node": "^22.7.5",
|
"@types/node": "^22.7.5",
|
||||||
"@types/react": "^18.3.11",
|
"@types/react": "^18.3.11",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
|
|
|
@ -2,7 +2,7 @@ import Header from '@/app/content/views/Header'
|
||||||
import Footer from '@/app/content/views/Footer'
|
import Footer from '@/app/content/views/Footer'
|
||||||
import Main from '@/app/content/views/Main'
|
import Main from '@/app/content/views/Main'
|
||||||
import AppButton from '@/app/content/views/AppButton'
|
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 { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
||||||
import RoomDomain from '@/domain/Room'
|
import RoomDomain from '@/domain/Room'
|
||||||
import UserInfoDomain from '@/domain/UserInfo'
|
import UserInfoDomain from '@/domain/UserInfo'
|
||||||
|
@ -10,6 +10,7 @@ import Setup from '@/app/content/views/Setup'
|
||||||
import MessageListDomain from '@/domain/MessageList'
|
import MessageListDomain from '@/domain/MessageList'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { Toaster } from 'sonner'
|
import { Toaster } from 'sonner'
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
|
|
||||||
import DanmakuContainer from './components/DanmakuContainer'
|
import DanmakuContainer from './components/DanmakuContainer'
|
||||||
import DanmakuDomain from '@/domain/Danmaku'
|
import DanmakuDomain from '@/domain/Danmaku'
|
||||||
|
@ -31,8 +32,8 @@ export default function App() {
|
||||||
const danmakuDomain = useRemeshDomain(DanmakuDomain())
|
const danmakuDomain = useRemeshDomain(DanmakuDomain())
|
||||||
const danmakuIsEnabled = useRemeshQuery(danmakuDomain.query.IsEnabledQuery())
|
const danmakuIsEnabled = useRemeshQuery(danmakuDomain.query.IsEnabledQuery())
|
||||||
const userInfoSetFinished = useRemeshQuery(userInfoDomain.query.UserInfoSetIsFinishedQuery())
|
const userInfoSetFinished = useRemeshQuery(userInfoDomain.query.UserInfoSetIsFinishedQuery())
|
||||||
const userInfoLoadFinished = useRemeshQuery(userInfoDomain.query.UserInfoLoadIsFinishedQuery())
|
|
||||||
const messageListLoadFinished = useRemeshQuery(messageListDomain.query.LoadIsFinishedQuery())
|
const messageListLoadFinished = useRemeshQuery(messageListDomain.query.LoadIsFinishedQuery())
|
||||||
|
const userInfoLoadFinished = useRemeshQuery(userInfoDomain.query.UserInfoLoadIsFinishedQuery())
|
||||||
|
|
||||||
const notUserInfo = userInfoLoadFinished && !userInfoSetFinished
|
const notUserInfo = userInfoLoadFinished && !userInfoSetFinished
|
||||||
|
|
||||||
|
@ -58,14 +59,21 @@ export default function App() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppContainer>
|
<AppMain>
|
||||||
<Header />
|
<Header />
|
||||||
<Main />
|
<Main />
|
||||||
<Footer />
|
<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>
|
<Toaster richColors offset="70px" visibleToasts={1} position="top-center"></Toaster>
|
||||||
</AppContainer>
|
</AppMain>
|
||||||
<AppButton></AppButton>
|
<AppButton></AppButton>
|
||||||
|
|
||||||
<DanmakuContainer ref={danmakuContainerRef} />
|
<DanmakuContainer ref={danmakuContainerRef} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { type FC } from 'react'
|
import { type FC } from 'react'
|
||||||
import { FrownIcon, ThumbsUpIcon } from 'lucide-react'
|
import { FrownIcon, ThumbsUpIcon } from 'lucide-react'
|
||||||
import { Badge } from '@/components/ui/Badge'
|
|
||||||
import LikeButton from './LikeButton'
|
import LikeButton from './LikeButton'
|
||||||
import FormatDate from './FormatDate'
|
import FormatDate from './FormatDate'
|
||||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/Avatar'
|
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/Avatar'
|
||||||
|
|
|
@ -14,6 +14,7 @@ const MessageList: FC<MessageListProps> = ({ children }) => {
|
||||||
return (
|
return (
|
||||||
<ScrollArea ref={setScrollParentRef}>
|
<ScrollArea ref={setScrollParentRef}>
|
||||||
<Virtuoso
|
<Virtuoso
|
||||||
|
defaultItemHeight={108}
|
||||||
followOutput={(isAtBottom: boolean) => (isAtBottom ? 'smooth' : 'auto')}
|
followOutput={(isAtBottom: boolean) => (isAtBottom ? 'smooth' : 'auto')}
|
||||||
initialTopMostItemIndex={{ index: 'LAST', align: 'end' }}
|
initialTopMostItemIndex={{ index: 'LAST', align: 'end' }}
|
||||||
data={children}
|
data={children}
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
import { type FC, useState, type MouseEvent, useRef } from 'react'
|
import { type FC, useState, type MouseEvent, useRef, useEffect, useLayoutEffect } from 'react'
|
||||||
import { SettingsIcon, MoonIcon, SunIcon } from 'lucide-react'
|
import { SettingsIcon, MoonIcon, SunIcon, HandIcon } from 'lucide-react'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
|
||||||
import { browser } from 'wxt/browser'
|
|
||||||
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { EVENT } from '@/constants/event'
|
import { EVENT } from '@/constants/event'
|
||||||
|
@ -20,6 +19,8 @@ import LogoIcon6 from '@/assets/images/logo-6.svg'
|
||||||
import AppStatusDomain from '@/domain/AppStatus'
|
import AppStatusDomain from '@/domain/AppStatus'
|
||||||
import { getDay } from 'date-fns'
|
import { getDay } from 'date-fns'
|
||||||
import { messenger } from '@/messenger'
|
import { messenger } from '@/messenger'
|
||||||
|
import useDarg from '@/hooks/useDarg'
|
||||||
|
import { useWindowSize } from 'react-use'
|
||||||
|
|
||||||
const AppButton: FC = () => {
|
const AppButton: FC = () => {
|
||||||
const send = useRemeshSend()
|
const send = useRemeshSend()
|
||||||
|
@ -29,6 +30,9 @@ const AppButton: FC = () => {
|
||||||
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||||
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
|
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
|
||||||
const toastDomain = useRemeshDomain(ToastDomain())
|
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 DayLogo = [LogoIcon0, LogoIcon1, LogoIcon2, LogoIcon3, LogoIcon4, LogoIcon5, LogoIcon6][getDay(Date())]
|
||||||
|
|
||||||
const isDarkMode =
|
const isDarkMode =
|
||||||
|
@ -38,6 +42,21 @@ const AppButton: FC = () => {
|
||||||
|
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
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, () => {
|
useClickAway(menuRef, () => {
|
||||||
setMenuOpen(false)
|
setMenuOpen(false)
|
||||||
}, ['click'])
|
}, ['click'])
|
||||||
|
@ -65,7 +84,15 @@ const AppButton: FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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>
|
<AnimatePresence>
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
@ -92,13 +119,12 @@ const AppButton: FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button onClick={handleOpenOptionsPage} variant="outline" className="size-10 rounded-full p-0 shadow">
|
||||||
onClick={handleOpenOptionsPage}
|
|
||||||
variant="outline"
|
|
||||||
className="pointer-events-auto size-10 rounded-full p-0 shadow"
|
|
||||||
>
|
|
||||||
<SettingsIcon size={20} />
|
<SettingsIcon size={20} />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button ref={ref} variant="outline" className="size-10 cursor-grab rounded-full p-0 shadow">
|
||||||
|
<HandIcon size={20} />
|
||||||
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
|
@ -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
|
|
67
src/app/content/views/AppMain/index.tsx
Normal file
67
src/app/content/views/AppMain/index.tsx
Normal 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
|
|
@ -7,7 +7,6 @@ import PromptItem from '../../components/PromptItem'
|
||||||
import UserInfoDomain from '@/domain/UserInfo'
|
import UserInfoDomain from '@/domain/UserInfo'
|
||||||
import RoomDomain, { MessageType } from '@/domain/Room'
|
import RoomDomain, { MessageType } from '@/domain/Room'
|
||||||
import MessageListDomain from '@/domain/MessageList'
|
import MessageListDomain from '@/domain/MessageList'
|
||||||
import BlurFade from '@/components/magicui/BlurFade'
|
|
||||||
|
|
||||||
const Main: FC = () => {
|
const Main: FC = () => {
|
||||||
const send = useRemeshSend()
|
const send = useRemeshSend()
|
||||||
|
@ -39,7 +38,6 @@ const Main: FC = () => {
|
||||||
<MessageList>
|
<MessageList>
|
||||||
{messageList.map((message, index) =>
|
{messageList.map((message, index) =>
|
||||||
message.type === MessageType.Normal ? (
|
message.type === MessageType.Normal ? (
|
||||||
<BlurFade key={message.id} duration={0.1} yOffset={0}>
|
|
||||||
<MessageItem
|
<MessageItem
|
||||||
key={message.id}
|
key={message.id}
|
||||||
data={message}
|
data={message}
|
||||||
|
@ -47,16 +45,14 @@ const Main: FC = () => {
|
||||||
hate={message.hate}
|
hate={message.hate}
|
||||||
onLikeChange={() => handleLikeChange(message.id)}
|
onLikeChange={() => handleLikeChange(message.id)}
|
||||||
onHateChange={() => handleHateChange(message.id)}
|
onHateChange={() => handleHateChange(message.id)}
|
||||||
|
className="duration-300 animate-in fade-in-0"
|
||||||
></MessageItem>
|
></MessageItem>
|
||||||
</BlurFade>
|
|
||||||
) : (
|
) : (
|
||||||
<BlurFade key={message.id} duration={0.1} yOffset={0}>
|
|
||||||
<PromptItem
|
<PromptItem
|
||||||
key={message.id}
|
key={message.id}
|
||||||
data={message}
|
data={message}
|
||||||
className={`${index === 0 ? 'pt-4' : ''} ${index === messageList.length - 1 ? 'pb-4' : ''}`}
|
className={`${index === 0 ? 'pt-4' : ''} ${index === messageList.length - 1 ? 'pb-4' : ''}`}
|
||||||
></PromptItem>
|
></PromptItem>
|
||||||
</BlurFade>
|
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</MessageList>
|
</MessageList>
|
||||||
|
|
|
@ -68,6 +68,7 @@ const Setup: FC = () => {
|
||||||
const messageListDomain = useRemeshDomain(MessageListDomain())
|
const messageListDomain = useRemeshDomain(MessageListDomain())
|
||||||
|
|
||||||
const [userInfo, setUserInfo] = useState<UserInfo>()
|
const [userInfo, setUserInfo] = useState<UserInfo>()
|
||||||
|
|
||||||
const handleSetup = () => {
|
const handleSetup = () => {
|
||||||
send(userInfoDomain.command.UpdateUserInfoCommand(userInfo!))
|
send(userInfoDomain.command.UpdateUserInfoCommand(userInfo!))
|
||||||
send(messageListDomain.command.ClearListCommand())
|
send(messageListDomain.command.ClearListCommand())
|
||||||
|
|
|
@ -67,7 +67,7 @@ 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-2 w-full">
|
<div className="my-2 w-full">
|
||||||
<ScrollArea>
|
<ScrollArea scrollLock={false}>
|
||||||
<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" />
|
<ScrollBar orientation="horizontal" />
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
@ -106,7 +106,7 @@ const Markdown: FC<MarkdownProps> = ({ children = '', className }) => {
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
code: ({ className, ...props }) => (
|
code: ({ className, ...props }) => (
|
||||||
<ScrollArea>
|
<ScrollArea className="overscroll-y-auto" scrollLock={false}>
|
||||||
<code className={cn('text-sm', className)} {...props}></code>
|
<code className={cn('text-sm', className)} {...props}></code>
|
||||||
<ScrollBar orientation="horizontal" />
|
<ScrollBar orientation="horizontal" />
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
|
@ -5,10 +5,13 @@ import { cn } from '@/utils/index'
|
||||||
|
|
||||||
const ScrollArea = React.forwardRef<
|
const ScrollArea = React.forwardRef<
|
||||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & { scrollLock?: boolean }
|
||||||
>(({ className, children, ...props }, ref) => (
|
>(({ className, children, scrollLock = true, ...props }, ref) => (
|
||||||
<ScrollAreaPrimitive.Root className={cn('relative grid grid-rows-[1fr] 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={cn('size-full rounded-[inherit]', scrollLock ? 'overscroll-none' : 'overscroll-auto')}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</ScrollAreaPrimitive.Viewport>
|
</ScrollAreaPrimitive.Viewport>
|
||||||
<ScrollBar />
|
<ScrollBar />
|
||||||
|
|
|
@ -9,11 +9,13 @@ import { map } from 'rxjs'
|
||||||
export interface AppStatus {
|
export interface AppStatus {
|
||||||
open: boolean
|
open: boolean
|
||||||
unread: number
|
unread: number
|
||||||
|
position: { x: number; y: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultStatusState = {
|
export const defaultStatusState = {
|
||||||
open: false,
|
open: false,
|
||||||
unread: 0
|
unread: 0,
|
||||||
|
position: { x: window.innerWidth - 44, y: window.innerHeight - 22 }
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppStatusDomain = Remesh.domain({
|
const AppStatusDomain = Remesh.domain({
|
||||||
|
@ -32,8 +34,8 @@ const AppStatusDomain = Remesh.domain({
|
||||||
|
|
||||||
const StatusLoadIsFinishedQuery = domain.query({
|
const StatusLoadIsFinishedQuery = domain.query({
|
||||||
name: 'AppStatus.StatusLoadIsFinishedQuery',
|
name: 'AppStatus.StatusLoadIsFinishedQuery',
|
||||||
impl: () => {
|
impl: ({ get }) => {
|
||||||
return StatusLoadModule.query.IsFinishedQuery()
|
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({
|
const HasUnreadQuery = domain.query({
|
||||||
name: 'AppStatus.HasUnreadQuery',
|
name: 'AppStatus.HasUnreadQuery',
|
||||||
impl: ({ get }) => {
|
impl: ({ get }) => {
|
||||||
|
@ -68,6 +77,7 @@ const AppStatusDomain = Remesh.domain({
|
||||||
impl: ({ get }, value: boolean) => {
|
impl: ({ get }, value: boolean) => {
|
||||||
const status = get(StatusState())
|
const status = get(StatusState())
|
||||||
return UpdateStatusCommand({
|
return UpdateStatusCommand({
|
||||||
|
...status,
|
||||||
unread: value ? 0 : status.unread,
|
unread: value ? 0 : status.unread,
|
||||||
open: value
|
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({
|
const UpdateStatusCommand = domain.command({
|
||||||
name: 'AppStatus.UpdateStatusCommand',
|
name: 'AppStatus.UpdateStatusCommand',
|
||||||
impl: (_, value: AppStatus) => {
|
impl: (_, value: AppStatus) => {
|
||||||
|
@ -128,11 +149,13 @@ const AppStatusDomain = Remesh.domain({
|
||||||
OpenQuery,
|
OpenQuery,
|
||||||
UnreadQuery,
|
UnreadQuery,
|
||||||
HasUnreadQuery,
|
HasUnreadQuery,
|
||||||
|
PositionQuery,
|
||||||
StatusLoadIsFinishedQuery
|
StatusLoadIsFinishedQuery
|
||||||
},
|
},
|
||||||
command: {
|
command: {
|
||||||
UpdateOpenCommand,
|
UpdateOpenCommand,
|
||||||
UpdateUnreadCommand
|
UpdateUnreadCommand,
|
||||||
|
UpdatePositionCommand
|
||||||
},
|
},
|
||||||
event: {
|
event: {
|
||||||
SyncToStorageEvent
|
SyncToStorageEvent
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { Remesh } from 'remesh'
|
||||||
import { DanmakuExtern } from './externs/Danmaku'
|
import { DanmakuExtern } from './externs/Danmaku'
|
||||||
import RoomDomain, { TextMessage } from './Room'
|
import RoomDomain, { TextMessage } from './Room'
|
||||||
import UserInfoDomain from './UserInfo'
|
import UserInfoDomain from './UserInfo'
|
||||||
import { map, merge, of } from 'rxjs'
|
import { map, merge } from 'rxjs'
|
||||||
|
|
||||||
const DanmakuDomain = Remesh.domain({
|
const DanmakuDomain = Remesh.domain({
|
||||||
name: 'DanmakuDomain',
|
name: 'DanmakuDomain',
|
||||||
|
|
|
@ -4,7 +4,7 @@ import localStorageDriver from 'unstorage/drivers/localstorage'
|
||||||
import { LocalStorageExtern, IndexDBStorageExtern, BrowserSyncStorageExtern } from '@/domain/externs/Storage'
|
import { LocalStorageExtern, IndexDBStorageExtern, BrowserSyncStorageExtern } from '@/domain/externs/Storage'
|
||||||
import { STORAGE_NAME } from '@/constants/config'
|
import { STORAGE_NAME } from '@/constants/config'
|
||||||
import { webExtensionDriver } from '@/utils/webExtensionDriver'
|
import { webExtensionDriver } from '@/utils/webExtensionDriver'
|
||||||
import { browser } from 'wxt/browser'
|
|
||||||
import { Storage } from '@/domain/externs/Storage'
|
import { Storage } from '@/domain/externs/Storage'
|
||||||
import { EVENT } from '@/constants/event'
|
import { EVENT } from '@/constants/event'
|
||||||
|
|
||||||
|
@ -62,23 +62,3 @@ export const BrowserSyncStorageImpl = BrowserSyncStorageExtern.impl({
|
||||||
watch: browserSyncStorage.watch as Storage['watch'],
|
watch: browserSyncStorage.watch as Storage['watch'],
|
||||||
unwatch: browserSyncStorage.unwatch
|
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
|
|
||||||
// })
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { type RemeshEvent, type RemeshAction, type RemeshDomainContext, type RemeshExtern } from 'remesh'
|
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'
|
import { Storage, StorageValue } from '@/domain/externs/Storage'
|
||||||
|
|
||||||
|
|
91
src/hooks/useDarg.ts
Normal file
91
src/hooks/useDarg.ts
Normal 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
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useRef, useState } from 'react'
|
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
|
||||||
import { clamp, isInRange } from '@/utils'
|
import { clamp, isInRange } from '@/utils'
|
||||||
|
|
||||||
export interface ResizableOptions {
|
export interface ResizableOptions {
|
||||||
|
@ -11,7 +11,11 @@ export interface ResizableOptions {
|
||||||
const useResizable = (options: ResizableOptions) => {
|
const useResizable = (options: ResizableOptions) => {
|
||||||
const { minSize, maxSize, initSize = 0, direction } = options
|
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)
|
const position = useRef(0)
|
||||||
|
|
||||||
|
@ -67,13 +71,13 @@ const useResizable = (options: ResizableOptions) => {
|
||||||
[isHorizontal]
|
[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
|
// Watch ref: https://medium.com/@teh_builder/ref-objects-inside-useeffect-hooks-eb7c15198780
|
||||||
const setRef = useCallback(
|
const setHandleRef = useCallback(
|
||||||
(node: HTMLElement | null) => {
|
(node: HTMLElement | null) => {
|
||||||
if (ref.current) {
|
if (handlerRef.current) {
|
||||||
ref.current.removeEventListener('mousedown', handleStart)
|
handlerRef.current.removeEventListener('mousedown', handleStart)
|
||||||
document.removeEventListener('mouseup', handleEnd)
|
document.removeEventListener('mouseup', handleEnd)
|
||||||
document.removeEventListener('mousemove', handleMove)
|
document.removeEventListener('mousemove', handleMove)
|
||||||
}
|
}
|
||||||
|
@ -82,12 +86,12 @@ const useResizable = (options: ResizableOptions) => {
|
||||||
document.addEventListener('mouseup', handleEnd)
|
document.addEventListener('mouseup', handleEnd)
|
||||||
document.addEventListener('mousemove', handleMove)
|
document.addEventListener('mousemove', handleMove)
|
||||||
}
|
}
|
||||||
ref.current = node
|
handlerRef.current = node
|
||||||
},
|
},
|
||||||
[handleEnd, handleMove, handleStart]
|
[handleEnd, handleMove, handleStart]
|
||||||
)
|
)
|
||||||
|
|
||||||
return { size, ref: setRef }
|
return { size, ref: setHandleRef }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useResizable
|
export default useResizable
|
||||||
|
|
Loading…
Reference in a new issue