Merge branch 'develop'
This commit is contained in:
commit
165176b9a4
54 changed files with 10500 additions and 8274 deletions
2
.github/workflows/cd.yml
vendored
2
.github/workflows/cd.yml
vendored
|
@ -21,7 +21,7 @@ jobs:
|
||||||
- run: pnpm install --ignore-scripts
|
- run: pnpm install --ignore-scripts
|
||||||
- run: pnpm wxt prepare
|
- run: pnpm wxt prepare
|
||||||
- run: pnpm run lint
|
- run: pnpm run lint
|
||||||
- run: pnpm run tsc
|
- run: pnpm run check
|
||||||
|
|
||||||
release:
|
release:
|
||||||
needs: linter
|
needs: linter
|
||||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -14,4 +14,5 @@ web-ext.config.ts
|
||||||
*.pem
|
*.pem
|
||||||
*.xpi
|
*.xpi
|
||||||
*.zip
|
*.zip
|
||||||
|
.idea
|
||||||
|
|
||||||
|
|
|
@ -1,2 +1,2 @@
|
||||||
|
|
||||||
pnpm lint-staged && pnpm tsc
|
pnpm lint-staged && pnpm check
|
||||||
|
|
|
@ -34,8 +34,9 @@ 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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
// satisfies Linter.Config[]
|
// satisfies Linter.Config[]
|
||||||
|
|
45
package.json
45
package.json
|
@ -15,7 +15,7 @@
|
||||||
"pack:firefox": "wxt zip -b firefox",
|
"pack:firefox": "wxt zip -b firefox",
|
||||||
"lint": "eslint --fix --flag unstable_ts_config",
|
"lint": "eslint --fix --flag unstable_ts_config",
|
||||||
"clear": "rimraf .output",
|
"clear": "rimraf .output",
|
||||||
"tsc": "tsc --noEmit",
|
"check": "tsc --noEmit",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"postinstall": "wxt prepare"
|
"postinstall": "wxt prepare"
|
||||||
},
|
},
|
||||||
|
@ -45,7 +45,7 @@
|
||||||
"homepage": "https://github.com/molvqingtai/WebChat",
|
"homepage": "https://github.com/molvqingtai/WebChat",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@hookform/resolvers": "^3.9.0",
|
||||||
"@lottiefiles/dotlottie-react": "^0.9.1",
|
"@lottiefiles/dotlottie-react": "^0.9.2",
|
||||||
"@perfsee/jsonr": "^1.13.0",
|
"@perfsee/jsonr": "^1.13.0",
|
||||||
"@radix-ui/react-avatar": "^1.1.1",
|
"@radix-ui/react-avatar": "^1.1.1",
|
||||||
"@radix-ui/react-checkbox": "^1.1.2",
|
"@radix-ui/react-checkbox": "^1.1.2",
|
||||||
|
@ -54,6 +54,8 @@
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
"@radix-ui/react-popover": "^1.1.2",
|
"@radix-ui/react-popover": "^1.1.2",
|
||||||
|
"@radix-ui/react-portal": "^1.1.2",
|
||||||
|
"@radix-ui/react-presence": "^1.1.1",
|
||||||
"@radix-ui/react-radio-group": "^1.2.1",
|
"@radix-ui/react-radio-group": "^1.2.1",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.0",
|
"@radix-ui/react-scroll-area": "^1.2.0",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
|
@ -62,23 +64,22 @@
|
||||||
"@resreq/timer": "^1.1.6",
|
"@resreq/timer": "^1.1.6",
|
||||||
"@rtco/client": "^0.2.17",
|
"@rtco/client": "^0.2.17",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@webext-core/messaging": "^1.4.0",
|
"@webext-core/messaging": "^2.0.2",
|
||||||
"@webext-core/proxy-service": "^1.2.0",
|
"@webext-core/proxy-service": "^1.2.0",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"danmu": "^0.12.0",
|
"danmu": "^0.14.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"framer-motion": "^11.11.8",
|
"framer-motion": "^11.11.10",
|
||||||
"idb-keyval": "^6.2.1",
|
"idb-keyval": "^6.2.1",
|
||||||
"lucide-react": "^0.452.0",
|
"lucide-react": "^0.453.0",
|
||||||
"nanoid": "^5.0.7",
|
"nanoid": "^5.0.7",
|
||||||
"next-themes": "^0.3.0",
|
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.53.0",
|
"react-hook-form": "^7.53.1",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"react-use": "^17.5.1",
|
"react-use": "^17.5.1",
|
||||||
"react-virtuoso": "^4.10.4",
|
"react-virtuoso": "^4.12.0",
|
||||||
"remark-breaks": "^4.0.0",
|
"remark-breaks": "^4.0.0",
|
||||||
"remark-gfm": "^4.0.0",
|
"remark-gfm": "^4.0.0",
|
||||||
"remesh": "^4.2.2",
|
"remesh": "^4.2.2",
|
||||||
|
@ -86,7 +87,7 @@
|
||||||
"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.5.3",
|
"tailwind-merge": "^2.5.4",
|
||||||
"trystero": "^0.20.0",
|
"trystero": "^0.20.0",
|
||||||
"type-fest": "^4.26.1",
|
"type-fest": "^4.26.1",
|
||||||
"unstorage": "1.12.0",
|
"unstorage": "1.12.0",
|
||||||
|
@ -95,22 +96,22 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^19.5.0",
|
"@commitlint/cli": "^19.5.0",
|
||||||
"@commitlint/config-conventional": "^19.5.0",
|
"@commitlint/config-conventional": "^19.5.0",
|
||||||
"@eslint-react/eslint-plugin": "^1.15.0",
|
"@eslint-react/eslint-plugin": "^1.15.1",
|
||||||
"@eslint/js": "^9.12.0",
|
"@eslint/js": "^9.13.0",
|
||||||
"@semantic-release/changelog": "^6.0.3",
|
"@semantic-release/changelog": "^6.0.3",
|
||||||
"@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-plugin-tailwindcss": "^3.17.0",
|
|
||||||
"@types/eslint__js": "^8.42.3",
|
"@types/eslint__js": "^8.42.3",
|
||||||
"@types/node": "^22.7.5",
|
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
||||||
"@types/react": "^18.3.11",
|
"@types/node": "^22.8.1",
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@typescript-eslint/parser": "^8.8.1",
|
"@typescript-eslint/parser": "^8.11.0",
|
||||||
"@vitejs/plugin-react": "^4.3.2",
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"eslint": "^9.12.0",
|
"eslint": "^9.13.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.5",
|
"eslint-plugin-tailwindcss": "^3.17.5",
|
||||||
|
@ -123,14 +124,14 @@
|
||||||
"postcss-rem-to-responsive-pixel": "^6.0.2",
|
"postcss-rem-to-responsive-pixel": "^6.0.2",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"rimraf": "^6.0.1",
|
"rimraf": "^6.0.1",
|
||||||
"semantic-release": "^24.1.2",
|
"semantic-release": "^24.2.0",
|
||||||
"tailwindcss": "^3.4.13",
|
"tailwindcss": "^3.4.14",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"typescript-eslint": "^8.8.1",
|
"typescript-eslint": "^8.11.0",
|
||||||
"vite-plugin-svgr": "^4.2.0",
|
"vite-plugin-svgr": "^4.2.0",
|
||||||
"webext-bridge": "^6.0.1",
|
"webext-bridge": "^6.0.1",
|
||||||
"wxt": "^0.19.11"
|
"wxt": "^0.19.13"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,jsx,ts,tsx}": "eslint --fix --flag unstable_ts_config"
|
"*.{js,jsx,ts,tsx}": "eslint --fix --flag unstable_ts_config"
|
||||||
|
|
17246
pnpm-lock.yaml
17246
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
@ -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,9 +10,12 @@ 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'
|
||||||
|
import AppStatusDomain from '@/domain/AppStatus'
|
||||||
|
import { cn } from '@/utils'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fix requestAnimationFrame error in jest
|
* Fix requestAnimationFrame error in jest
|
||||||
|
@ -31,9 +34,12 @@ 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 appStatusDomain = useRemeshDomain(AppStatusDomain())
|
||||||
|
const appStatusLoadIsFinished = useRemeshQuery(appStatusDomain.query.StatusLoadIsFinishedQuery())
|
||||||
|
|
||||||
|
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
|
||||||
const notUserInfo = userInfoLoadFinished && !userInfoSetFinished
|
const notUserInfo = userInfoLoadFinished && !userInfoSetFinished
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -57,16 +63,30 @@ export default function App() {
|
||||||
}, [danmakuIsEnabled])
|
}, [danmakuIsEnabled])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
appStatusLoadIsFinished && (
|
||||||
<AppContainer>
|
<div id="app" className={cn('contents', userInfo?.themeMode)}>
|
||||||
<Header />
|
<AppMain>
|
||||||
<Main />
|
<Header />
|
||||||
<Footer />
|
<Main />
|
||||||
{notUserInfo && <Setup />}
|
<Footer />
|
||||||
<Toaster richColors offset="70px" visibleToasts={1} position="top-center"></Toaster>
|
<AnimatePresence>
|
||||||
</AppContainer>
|
{notUserInfo && (
|
||||||
<AppButton></AppButton>
|
<motion.div
|
||||||
<DanmakuContainer ref={danmakuContainerRef} />
|
className="contents"
|
||||||
</>
|
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>
|
||||||
|
</AppMain>
|
||||||
|
<AppButton></AppButton>
|
||||||
|
|
||||||
|
<DanmakuContainer ref={danmakuContainerRef} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,7 +20,7 @@ const DanmakuMessage: FC<PromptItemProps> = ({ data, className, onClick, onMouse
|
||||||
onMouseLeave={onMouseLeave}
|
onMouseLeave={onMouseLeave}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex justify-center pointer-events-auto visible gap-x-2 border px-2.5 py-0.5 rounded-full bg-primary/30 text-base font-medium text-white backdrop-blur-md',
|
'flex justify-center pointer-events-auto visible gap-x-2 border border-slate-50 px-2.5 py-0.5 rounded-full bg-primary/30 text-base font-medium text-white backdrop-blur-md',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
@ -10,7 +10,7 @@ export interface EmojiButtonProps {
|
||||||
onSelect?: (value: string) => void
|
onSelect?: (value: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const emojiGroups = chunk([...EMOJI_LIST], 8)
|
const emojiGroups = chunk([...EMOJI_LIST], 6)
|
||||||
|
|
||||||
// BUG: https://github.com/radix-ui/primitives/pull/2433
|
// BUG: https://github.com/radix-ui/primitives/pull/2433
|
||||||
// BUG https://github.com/radix-ui/primitives/issues/1666
|
// BUG https://github.com/radix-ui/primitives/issues/1666
|
||||||
|
@ -30,20 +30,23 @@ const EmojiButton: FC<EmojiButtonProps> = ({ onSelect }) => {
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="ghost" size="icon">
|
<Button variant="ghost" size="icon" className="dark:text-white">
|
||||||
<SmileIcon size={20} />
|
<SmileIcon size={20} />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="z-infinity w-72 rounded-xl px-0" onCloseAutoFocus={handleCloseAutoFocus}>
|
<PopoverContent
|
||||||
<ScrollArea className="size-72 px-3">
|
className="z-infinity w-64 overflow-hidden rounded-xl p-0 dark:bg-slate-900"
|
||||||
|
onCloseAutoFocus={handleCloseAutoFocus}
|
||||||
|
>
|
||||||
|
<ScrollArea className="size-64 p-1">
|
||||||
{emojiGroups.map((group, index) => {
|
{emojiGroups.map((group, index) => {
|
||||||
return (
|
return (
|
||||||
<div key={index} className="grid grid-cols-8">
|
<div key={index} className="grid grid-cols-6">
|
||||||
{group.map((emoji, index) => (
|
{group.map((emoji, index) => (
|
||||||
<Button
|
<Button
|
||||||
key={index}
|
key={index}
|
||||||
size="sm"
|
size="icon"
|
||||||
className="text-base"
|
className="text-xl"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => handleSelect(emoji)}
|
onClick={() => handleSelect(emoji)}
|
||||||
>
|
>
|
||||||
|
|
|
@ -33,8 +33,8 @@ const LikeButton: FC<LikeButtonProps> & { Icon: FC<LikeButtonIconProps> } = ({
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className={cn(
|
className={cn(
|
||||||
'grid items-center overflow-hidden rounded-full leading-none transition-all select-none',
|
'grid items-center overflow-hidden rounded-full leading-none transition-all select-none dark:bg-slate-600',
|
||||||
checked ? 'text-orange-500' : 'text-slate-500',
|
checked ? 'text-orange-500' : 'text-slate-500 dark:text-slate-100',
|
||||||
count ? 'grid-cols-[auto_1fr] gap-x-1' : 'grid-cols-[auto_0fr] gap-x-0'
|
count ? 'grid-cols-[auto_1fr] gap-x-1' : 'grid-cols-[auto_0fr] gap-x-0'
|
||||||
)}
|
)}
|
||||||
size="xs"
|
size="xs"
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
import { forwardRef, type ChangeEvent, CompositionEvent, type KeyboardEvent } from 'react'
|
import { forwardRef, type ChangeEvent, CompositionEvent, type KeyboardEvent } from 'react'
|
||||||
|
|
||||||
import { Textarea } from '@/components/ui/Textarea'
|
|
||||||
import { Markdown } from '@/components/Markdown'
|
|
||||||
import { cn } from '@/utils'
|
import { cn } from '@/utils'
|
||||||
|
import { Textarea } from '@/components/ui/Textarea'
|
||||||
import { ScrollArea } from '@/components/ui/ScrollArea'
|
import { ScrollArea } from '@/components/ui/ScrollArea'
|
||||||
|
|
||||||
export interface MessageInputProps {
|
export interface MessageInputProps {
|
||||||
|
@ -13,11 +12,16 @@ export interface MessageInputProps {
|
||||||
autoFocus?: boolean
|
autoFocus?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
onInput?: (e: ChangeEvent<HTMLTextAreaElement>) => void
|
onInput?: (e: ChangeEvent<HTMLTextAreaElement>) => void
|
||||||
onEnter?: (e: KeyboardEvent<HTMLTextAreaElement>) => void
|
onKeyDown?: (e: KeyboardEvent<HTMLTextAreaElement>) => void
|
||||||
onCompositionStart?: (e: CompositionEvent<HTMLTextAreaElement>) => void
|
onCompositionStart?: (e: CompositionEvent<HTMLTextAreaElement>) => void
|
||||||
onCompositionEnd?: (e: CompositionEvent<HTMLTextAreaElement>) => void
|
onCompositionEnd?: (e: CompositionEvent<HTMLTextAreaElement>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Need @ syntax highlighting? Waiting for textarea to support Highlight API
|
||||||
|
*
|
||||||
|
* @see https://github.com/w3c/csswg-drafts/issues/4603
|
||||||
|
*/
|
||||||
const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
|
@ -25,45 +29,34 @@ const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
||||||
className,
|
className,
|
||||||
maxLength = 500,
|
maxLength = 500,
|
||||||
onInput,
|
onInput,
|
||||||
onEnter,
|
onKeyDown,
|
||||||
onCompositionStart,
|
onCompositionStart,
|
||||||
onCompositionEnd,
|
onCompositionEnd,
|
||||||
preview,
|
|
||||||
autoFocus,
|
autoFocus,
|
||||||
disabled
|
disabled
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
||||||
if (e.key === 'Enter' && !(e.shiftKey || e.ctrlKey || e.altKey || e.metaKey)) {
|
|
||||||
e.preventDefault()
|
|
||||||
onEnter?.(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('relative', className)}>
|
<div className={cn('relative', className)}>
|
||||||
{preview ? (
|
<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">
|
||||||
<Markdown className="max-h-28 rounded-lg border border-input bg-gray-50 2xl:max-h-40">{value}</Markdown>
|
<Textarea
|
||||||
) : (
|
ref={ref}
|
||||||
<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">
|
onKeyDown={onKeyDown}
|
||||||
<Textarea
|
autoFocus={autoFocus}
|
||||||
ref={ref}
|
maxLength={maxLength}
|
||||||
onKeyDown={handleKeyDown}
|
className="box-border resize-none whitespace-pre-wrap break-words border-none bg-gray-50 pb-5 [field-sizing:content] [word-break:break-word] focus:ring-0 focus:ring-offset-0 dark:bg-slate-800"
|
||||||
autoFocus={autoFocus}
|
rows={2}
|
||||||
maxLength={maxLength}
|
value={value}
|
||||||
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"
|
spellCheck={false}
|
||||||
rows={2}
|
onCompositionStart={onCompositionStart}
|
||||||
value={value}
|
onCompositionEnd={onCompositionEnd}
|
||||||
onCompositionStart={onCompositionStart}
|
placeholder="Type your message here."
|
||||||
onCompositionEnd={onCompositionEnd}
|
onInput={onInput}
|
||||||
placeholder="Type your message here."
|
disabled={disabled}
|
||||||
onInput={onInput}
|
/>
|
||||||
disabled={disabled}
|
</ScrollArea>
|
||||||
/>
|
<div className="absolute bottom-1 right-3 rounded-lg text-xs text-slate-400 dark:text-slate-50">
|
||||||
</ScrollArea>
|
|
||||||
)}
|
|
||||||
<div className="absolute bottom-1 right-3 rounded-lg text-xs text-slate-400">
|
|
||||||
{value?.length ?? 0}/{maxLength}
|
{value?.length ?? 0}/{maxLength}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -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'
|
||||||
|
@ -26,10 +25,31 @@ const MessageItem: FC<MessageItemProps> = (props) => {
|
||||||
const handleHateChange = (checked: boolean) => {
|
const handleHateChange = (checked: boolean) => {
|
||||||
props.onHateChange?.(checked)
|
props.onHateChange?.(checked)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let content = props.data.body
|
||||||
|
|
||||||
|
// Check if the field exists, compatible with old data
|
||||||
|
if (props.data.atUsers) {
|
||||||
|
const atUserPositions = props.data.atUsers.flatMap((user) =>
|
||||||
|
user.positions.map((position) => ({ username: user.username, userId: user.userId, position }))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Replace from back to front according to position to avoid affecting previous indices
|
||||||
|
atUserPositions
|
||||||
|
.sort((a, b) => b.position[0] - a.position[0])
|
||||||
|
.forEach(({ position, username }) => {
|
||||||
|
const [start, end] = position
|
||||||
|
content = `${content.slice(0, start)} **@${username}** ${content.slice(end + 1)}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-index={props.index}
|
data-index={props.index}
|
||||||
className={cn('box-border grid grid-cols-[auto_1fr] gap-x-2 px-4 first:pt-4 last:pb-4', props.className)}
|
className={cn(
|
||||||
|
'box-border grid grid-cols-[auto_1fr] gap-x-2 px-4 first:pt-4 last:pb-4 dark:text-slate-50',
|
||||||
|
props.className
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Avatar>
|
<Avatar>
|
||||||
<AvatarImage src={props.data.userAvatar} className="size-full" alt="avatar" />
|
<AvatarImage src={props.data.userAvatar} className="size-full" alt="avatar" />
|
||||||
|
@ -37,14 +57,14 @@ const MessageItem: FC<MessageItemProps> = (props) => {
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
<div className="grid grid-cols-[1fr_auto] items-center gap-x-2 leading-none">
|
<div className="grid grid-cols-[1fr_auto] items-center gap-x-2 leading-none">
|
||||||
<div className="truncate text-sm font-semibold text-slate-600">{props.data.username}</div>
|
<div className="truncate text-sm font-semibold text-slate-600 dark:text-slate-50">{props.data.username}</div>
|
||||||
<FormatDate className="text-xs text-slate-400" date={props.data.date}></FormatDate>
|
<FormatDate className="text-xs text-slate-400 dark:text-slate-100" date={props.data.date}></FormatDate>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="pb-2">
|
<div className="pb-2">
|
||||||
<Markdown>{props.data.body}</Markdown>
|
<Markdown>{content}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-flow-col justify-end gap-x-2 leading-none">
|
<div className="grid grid-flow-col justify-end gap-x-2 leading-none dark:text-slate-600">
|
||||||
<LikeButton
|
<LikeButton
|
||||||
checked={props.like}
|
checked={props.like}
|
||||||
onChange={(checked) => handleLikeChange(checked)}
|
onChange={(checked) => handleLikeChange(checked)}
|
||||||
|
|
|
@ -12,8 +12,9 @@ const MessageList: FC<MessageListProps> = ({ children }) => {
|
||||||
const [scrollParentRef, setScrollParentRef] = useState<HTMLDivElement | null>(null)
|
const [scrollParentRef, setScrollParentRef] = useState<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea ref={setScrollParentRef}>
|
<ScrollArea ref={setScrollParentRef} className="dark:bg-slate-900">
|
||||||
<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}
|
||||||
|
|
|
@ -12,8 +12,8 @@ export interface PromptItemProps {
|
||||||
|
|
||||||
const PromptItem: FC<PromptItemProps> = ({ data, className }) => {
|
const PromptItem: FC<PromptItemProps> = ({ data, className }) => {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex justify-center py-1 px-4', className)}>
|
<div className={cn('flex justify-center py-1 px-4 ', className)}>
|
||||||
<Badge variant="secondary" className="gap-x-2 rounded-full px-2 font-medium text-slate-400">
|
<Badge variant="secondary" className="gap-x-2 rounded-full px-2 font-medium text-slate-400 dark:bg-slate-800">
|
||||||
<Avatar className="size-4">
|
<Avatar className="size-4">
|
||||||
<AvatarImage src={data.userAvatar} className="size-full" alt="avatar" />
|
<AvatarImage src={data.userAvatar} className="size-full" alt="avatar" />
|
||||||
<AvatarFallback>{data.username.at(0)}</AvatarFallback>
|
<AvatarFallback>{data.username.at(0)}</AvatarFallback>
|
||||||
|
|
|
@ -2,7 +2,7 @@ import React from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { Remesh } from 'remesh'
|
import { Remesh } from 'remesh'
|
||||||
import { RemeshRoot, RemeshScope } from 'remesh-react'
|
import { RemeshRoot, RemeshScope } from 'remesh-react'
|
||||||
import { RemeshLogger } from 'remesh-logger'
|
// import { RemeshLogger } from 'remesh-logger'
|
||||||
import { defineContentScript } from 'wxt/sandbox'
|
import { defineContentScript } from 'wxt/sandbox'
|
||||||
import { createShadowRootUi } from 'wxt/client'
|
import { createShadowRootUi } from 'wxt/client'
|
||||||
|
|
||||||
|
@ -15,8 +15,8 @@ import { ToastImpl } from '@/domain/impls/Toast'
|
||||||
import { PeerRoomImpl } from '@/domain/impls/PeerRoom2'
|
import { PeerRoomImpl } from '@/domain/impls/PeerRoom2'
|
||||||
import '@/assets/styles/tailwind.css'
|
import '@/assets/styles/tailwind.css'
|
||||||
import '@/assets/styles/sonner.css'
|
import '@/assets/styles/sonner.css'
|
||||||
import { createElement } from '@/utils'
|
|
||||||
import NotificationDomain from '@/domain/Notification'
|
import NotificationDomain from '@/domain/Notification'
|
||||||
|
import { createElement } from '@/utils'
|
||||||
|
|
||||||
export default defineContentScript({
|
export default defineContentScript({
|
||||||
cssInjectionMode: 'ui',
|
cssInjectionMode: 'ui',
|
||||||
|
@ -24,6 +24,13 @@ export default defineContentScript({
|
||||||
matches: ['https://*/*'],
|
matches: ['https://*/*'],
|
||||||
excludeMatches: ['*://localhost/*', '*://127.0.0.1/*', '*://*.csdn.net/*', '*://*.csdn.com/*'],
|
excludeMatches: ['*://localhost/*', '*://127.0.0.1/*', '*://*.csdn.net/*', '*://*.csdn.com/*'],
|
||||||
async main(ctx) {
|
async main(ctx) {
|
||||||
|
window.CSS.registerProperty({
|
||||||
|
name: '--shimmer-angle',
|
||||||
|
syntax: '<angle>',
|
||||||
|
inherits: false,
|
||||||
|
initialValue: '0deg'
|
||||||
|
})
|
||||||
|
|
||||||
const store = Remesh.store({
|
const store = Remesh.store({
|
||||||
externs: [
|
externs: [
|
||||||
LocalStorageImpl,
|
LocalStorageImpl,
|
||||||
|
@ -45,9 +52,8 @@ export default defineContentScript({
|
||||||
mode: 'open',
|
mode: 'open',
|
||||||
isolateEvents: ['keyup', 'keydown', 'keypress'],
|
isolateEvents: ['keyup', 'keydown', 'keypress'],
|
||||||
onMount: (container) => {
|
onMount: (container) => {
|
||||||
const app = createElement('<div id="app"></div>')
|
const app = createElement('<div id="root"></div>')
|
||||||
container.append(app)
|
container.append(app)
|
||||||
|
|
||||||
const root = createRoot(app)
|
const root = createRoot(app)
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|
|
@ -1,15 +1,13 @@
|
||||||
import { type FC, useState, type MouseEvent, useRef } from 'react'
|
import { type FC, useState, type MouseEvent, useEffect } 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'
|
||||||
import UserInfoDomain from '@/domain/UserInfo'
|
import UserInfoDomain from '@/domain/UserInfo'
|
||||||
import useClickAway from '@/hooks/useClickAway'
|
import useTriggerAway from '@/hooks/useTriggerAway'
|
||||||
import { checkSystemDarkMode, cn } from '@/utils'
|
import { checkSystemDarkMode, cn } from '@/utils'
|
||||||
import ToastDomain from '@/domain/Toast'
|
|
||||||
import LogoIcon0 from '@/assets/images/logo-0.svg'
|
import LogoIcon0 from '@/assets/images/logo-0.svg'
|
||||||
import LogoIcon1 from '@/assets/images/logo-1.svg'
|
import LogoIcon1 from '@/assets/images/logo-1.svg'
|
||||||
import LogoIcon2 from '@/assets/images/logo-2.svg'
|
import LogoIcon2 from '@/assets/images/logo-2.svg'
|
||||||
|
@ -20,15 +18,22 @@ 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 useWindowResize from '@/hooks/useWindowResize'
|
||||||
|
|
||||||
const AppButton: FC = () => {
|
export interface AppButtonProps {
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppButton: FC<AppButtonProps> = ({ className }) => {
|
||||||
const send = useRemeshSend()
|
const send = useRemeshSend()
|
||||||
const appStatusDomain = useRemeshDomain(AppStatusDomain())
|
const appStatusDomain = useRemeshDomain(AppStatusDomain())
|
||||||
const appOpenStatus = useRemeshQuery(appStatusDomain.query.OpenQuery())
|
const appOpenStatus = useRemeshQuery(appStatusDomain.query.OpenQuery())
|
||||||
const hasUnreadQuery = useRemeshQuery(appStatusDomain.query.HasUnreadQuery())
|
const hasUnreadQuery = useRemeshQuery(appStatusDomain.query.HasUnreadQuery())
|
||||||
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 appPosition = useRemeshQuery(appStatusDomain.query.PositionQuery())
|
||||||
|
|
||||||
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 =
|
||||||
|
@ -36,11 +41,28 @@ const AppButton: FC = () => {
|
||||||
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
setRef: appButtonRef
|
||||||
|
} = useDarg({
|
||||||
|
initX: appPosition.x,
|
||||||
|
initY: appPosition.y,
|
||||||
|
minX: 50,
|
||||||
|
maxX: window.innerWidth - 50,
|
||||||
|
maxY: window.innerHeight - 22,
|
||||||
|
minY: window.innerHeight / 2
|
||||||
|
})
|
||||||
|
|
||||||
useClickAway(menuRef, () => {
|
useWindowResize(({ width, height }) => {
|
||||||
setMenuOpen(false)
|
send(appStatusDomain.command.UpdatePositionCommand({ x: width - 50, y: height - 22 }))
|
||||||
}, ['click'])
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
send(appStatusDomain.command.UpdatePositionCommand({ x, y }))
|
||||||
|
}, [x, y])
|
||||||
|
|
||||||
|
const { setRef: appMenuRef } = useTriggerAway(['click'], () => setMenuOpen(false))
|
||||||
|
|
||||||
const handleToggleMenu = (e: MouseEvent<HTMLButtonElement>) => {
|
const handleToggleMenu = (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
@ -49,7 +71,6 @@ const AppButton: FC = () => {
|
||||||
|
|
||||||
const handleSwitchTheme = () => {
|
const handleSwitchTheme = () => {
|
||||||
if (userInfo) {
|
if (userInfo) {
|
||||||
send(toastDomain.command.WarningCommand('Developer is too lazy~'))
|
|
||||||
send(userInfoDomain.command.UpdateUserInfoCommand({ ...userInfo, themeMode: isDarkMode ? 'light' : 'dark' }))
|
send(userInfoDomain.command.UpdateUserInfoCommand({ ...userInfo, themeMode: isDarkMode ? 'light' : 'dark' }))
|
||||||
} else {
|
} else {
|
||||||
handleOpenOptionsPage()
|
handleOpenOptionsPage()
|
||||||
|
@ -65,7 +86,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={appMenuRef}
|
||||||
|
className={cn('fixed bottom-5 right-5 z-infinity grid w-min select-none justify-center gap-y-3', className)}
|
||||||
|
style={{
|
||||||
|
left: `calc(${appPosition.x}px)`,
|
||||||
|
bottom: `calc(100vh - ${appPosition.y}px)`,
|
||||||
|
transform: 'translateX(-50%)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
@ -84,7 +113,7 @@ const AppButton: FC = () => {
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute grid grid-rows-[repeat(2,minmax(0,2.5rem))] w-full justify-center items-center transition-all duration-500',
|
'absolute grid grid-rows-[repeat(2,minmax(0,2.5rem))] w-full justify-center items-center transition-all duration-500',
|
||||||
isDarkMode ? 'top-0' : '-top-10',
|
isDarkMode ? 'top-0' : '-top-10',
|
||||||
isDarkMode ? 'bg-slate-800 text-white' : 'bg-white text-orange-400'
|
isDarkMode ? 'bg-slate-950 text-white' : 'bg-white text-orange-400'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MoonIcon size={20} />
|
<MoonIcon size={20} />
|
||||||
|
@ -92,20 +121,19 @@ 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={appButtonRef} variant="outline" className="size-10 cursor-grab rounded-full p-0 shadow">
|
||||||
|
<HandIcon size={20} />
|
||||||
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleToggleApp}
|
onClick={handleToggleApp}
|
||||||
onContextMenu={handleToggleMenu}
|
onContextMenu={handleToggleMenu}
|
||||||
className="relative z-20 size-11 rounded-full p-0 text-xs shadow-lg shadow-slate-500/50"
|
className="relative z-20 size-11 rounded-full p-0 text-xs shadow-lg shadow-slate-500/50 after:absolute after:-inset-0.5 after:z-10 after:animate-[shimmer_2s_linear_infinite] after:rounded-full after:bg-[conic-gradient(from_var(--shimmer-angle),theme(colors.slate.500)_0%,theme(colors.white)_10%,theme(colors.slate.500)_20%)]"
|
||||||
>
|
>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{hasUnreadQuery && (
|
{hasUnreadQuery && (
|
||||||
|
@ -114,7 +142,7 @@ const AppButton: FC = () => {
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.1 }}
|
transition={{ duration: 0.1 }}
|
||||||
className="absolute -right-1 -top-1 flex size-5 items-center justify-center"
|
className="absolute -right-1 -top-1 z-30 flex size-5 items-center justify-center"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn('absolute inline-flex size-full animate-ping rounded-full opacity-75', 'bg-orange-400')}
|
className={cn('absolute inline-flex size-full animate-ping rounded-full opacity-75', 'bg-orange-400')}
|
||||||
|
@ -123,7 +151,8 @@ const AppButton: FC = () => {
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
<DayLogo className="max-h-full max-w-full"></DayLogo>
|
|
||||||
|
<DayLogo className="relative z-20 max-h-full max-w-full overflow-hidden"></DayLogo>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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
|
|
69
src/app/content/views/AppMain/index.tsx
Normal file
69
src/app/content/views/AppMain/index.tsx
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
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 useWindowResize from '@/hooks/useWindowResize'
|
||||||
|
|
||||||
|
export interface AppMainProps {
|
||||||
|
children?: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppMain: FC<AppMainProps> = ({ children, className }) => {
|
||||||
|
const appStatusDomain = useRemeshDomain(AppStatusDomain())
|
||||||
|
const appOpenStatus = useRemeshQuery(appStatusDomain.query.OpenQuery())
|
||||||
|
const { x, y } = useRemeshQuery(appStatusDomain.query.PositionQuery())
|
||||||
|
|
||||||
|
const { width } = useWindowResize()
|
||||||
|
|
||||||
|
const isOnRightSide = x >= width / 2 + 50
|
||||||
|
|
||||||
|
const { size, setRef } = 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 dark:bg-slate-950 font-sans shadow-2xl`,
|
||||||
|
className,
|
||||||
|
{ 'transition-transform': isAnimationComplete }
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<div
|
||||||
|
ref={setRef}
|
||||||
|
className={cn(
|
||||||
|
'absolute inset-y-3 z-infinity w-1 dark:bg-slate-600 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
|
|
@ -1,4 +1,4 @@
|
||||||
import { ChangeEvent, useRef, type FC } from 'react'
|
import { ChangeEvent, useMemo, useRef, useState, KeyboardEvent, type FC } from 'react'
|
||||||
import { CornerDownLeftIcon } from 'lucide-react'
|
import { CornerDownLeftIcon } from 'lucide-react'
|
||||||
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
||||||
import MessageInput from '../../components/MessageInput'
|
import MessageInput from '../../components/MessageInput'
|
||||||
|
@ -7,48 +7,289 @@ import { Button } from '@/components/ui/Button'
|
||||||
import MessageInputDomain from '@/domain/MessageInput'
|
import MessageInputDomain from '@/domain/MessageInput'
|
||||||
import { MESSAGE_MAX_LENGTH } from '@/constants/config'
|
import { MESSAGE_MAX_LENGTH } from '@/constants/config'
|
||||||
import RoomDomain from '@/domain/Room'
|
import RoomDomain from '@/domain/Room'
|
||||||
|
import useCursorPosition from '@/hooks/useCursorPosition'
|
||||||
|
import useShareRef from '@/hooks/useShareRef'
|
||||||
|
import { Presence } from '@radix-ui/react-presence'
|
||||||
|
import { Portal } from '@radix-ui/react-portal'
|
||||||
|
import useTriggerAway from '@/hooks/useTriggerAway'
|
||||||
|
import { ScrollArea } from '@/components/ui/ScrollArea'
|
||||||
|
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'
|
||||||
|
import UserInfoDomain from '@/domain/UserInfo'
|
||||||
|
import { cn, getRootNode, getTextSimilarity } from '@/utils'
|
||||||
|
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
|
||||||
|
import { AvatarImage } from '@radix-ui/react-avatar'
|
||||||
|
import ToastDomain from '@/domain/Toast'
|
||||||
|
|
||||||
const Footer: FC = () => {
|
const Footer: FC = () => {
|
||||||
const send = useRemeshSend()
|
const send = useRemeshSend()
|
||||||
|
const toastDomain = useRemeshDomain(ToastDomain())
|
||||||
const roomDomain = useRemeshDomain(RoomDomain())
|
const roomDomain = useRemeshDomain(RoomDomain())
|
||||||
const messageInputDomain = useRemeshDomain(MessageInputDomain())
|
const messageInputDomain = useRemeshDomain(MessageInputDomain())
|
||||||
const message = useRemeshQuery(messageInputDomain.query.MessageQuery())
|
const message = useRemeshQuery(messageInputDomain.query.MessageQuery())
|
||||||
|
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||||
|
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
|
||||||
|
const userList = useRemeshQuery(roomDomain.query.UserListQuery())
|
||||||
|
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const isComposing = useRef(false)
|
const { x, y, selectionStart, selectionEnd, setRef } = useCursorPosition()
|
||||||
|
|
||||||
const handleInput = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
const [autoCompleteListShow, setAutoCompleteListShow] = useState(false)
|
||||||
send(messageInputDomain.command.InputCommand(e.target.value))
|
const [scrollParentRef, setScrollParentRef] = useState<HTMLDivElement | null>(null)
|
||||||
}
|
const autoCompleteListRef = useRef<HTMLDivElement>(null)
|
||||||
|
const { setRef: setAutoCompleteListRef } = useTriggerAway(['click'], () => setAutoCompleteListShow(false))
|
||||||
|
const shareAutoCompleteListRef = useShareRef(setAutoCompleteListRef, autoCompleteListRef)
|
||||||
|
const isComposing = useRef(false)
|
||||||
|
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
||||||
|
|
||||||
|
const shareRef = useShareRef(inputRef, setRef)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When inserting a username using the @ syntax, record the username's position information and the mapping relationship between the position information and userId to distinguish between users with the same name.
|
||||||
|
*/
|
||||||
|
const atUserRecord = useRef<Map<string, Set<[number, number]>>>(new Map())
|
||||||
|
|
||||||
|
const updateAtUserAtRecord = useMemo(
|
||||||
|
() => (message: string, start: number, end: number, offset: number, atUserId?: string) => {
|
||||||
|
const positions: [number, number] = [start, end]
|
||||||
|
|
||||||
|
// If the editing position is before the end position of @user, update the editing position.
|
||||||
|
// "@user" => "E@user"
|
||||||
|
// "@user" => "@useEr"
|
||||||
|
// "@user" => "@user @user"
|
||||||
|
atUserRecord.current.forEach((item, userId) => {
|
||||||
|
const positionList = [...item].map<[number, number]>((item) => {
|
||||||
|
const inBefore = Math.min(start, end) <= item[1]
|
||||||
|
return inBefore ? [item[0] + offset + (end - start), item[1] + offset + (end - start)] : item
|
||||||
|
})
|
||||||
|
atUserRecord.current.set(userId, new Set(positionList))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Insert a new @user record
|
||||||
|
if (atUserId) {
|
||||||
|
atUserRecord.current.set(atUserId, atUserRecord.current.get(atUserId)?.add(positions) ?? new Set([positions]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// After moving, check if the @user in the message matches the saved position record. If not, it means the @user has been edited, so delete that record.
|
||||||
|
// Filter out records where the stored position does not match the actual position.
|
||||||
|
atUserRecord.current.forEach((item, userId) => {
|
||||||
|
// Pre-calculate the offset after InputCommand
|
||||||
|
const positionList = [...item].filter((item) => {
|
||||||
|
const username = message.slice(item[0], item[1] + 1)
|
||||||
|
return username === `@${userList.find((user) => user.userId === userId)?.username}`
|
||||||
|
})
|
||||||
|
if (positionList.length) {
|
||||||
|
atUserRecord.current.set(userId, new Set(positionList))
|
||||||
|
} else {
|
||||||
|
atUserRecord.current.delete(userId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[userList]
|
||||||
|
)
|
||||||
|
|
||||||
|
const [selectedUserIndex, setSelectedUserIndex] = useState(0)
|
||||||
|
const [searchNameKeyword, setSearchNameKeyword] = useState('')
|
||||||
|
|
||||||
|
const autoCompleteList = useMemo(() => {
|
||||||
|
return userList
|
||||||
|
.filter((user) => user.userId !== userInfo?.id)
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
similarity: getTextSimilarity(searchNameKeyword.toLowerCase(), item.username.toLowerCase())
|
||||||
|
}))
|
||||||
|
.toSorted((a, b) => b.similarity - a.similarity)
|
||||||
|
}, [searchNameKeyword, userList, userInfo])
|
||||||
|
|
||||||
|
const selectedUser = autoCompleteList.find((_, index) => index === selectedUserIndex)!
|
||||||
|
|
||||||
const handleSend = () => {
|
const handleSend = () => {
|
||||||
if (isComposing.current) return
|
if (!`${message}`.trim()) {
|
||||||
if (!message.trim()) return
|
return send(toastDomain.command.WarningCommand('Message cannot be empty.'))
|
||||||
send(roomDomain.command.SendTextMessageCommand(message.trim()))
|
}
|
||||||
|
|
||||||
|
const atUsers = [...atUserRecord.current]
|
||||||
|
.map(([userId, positions]) => {
|
||||||
|
const user = userList.find((user) => user.userId === userId)
|
||||||
|
return (user ? { ...user, positions: [...positions] } : undefined)!
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
send(roomDomain.command.SendTextMessageCommand({ body: message, atUsers }))
|
||||||
send(messageInputDomain.command.ClearCommand())
|
send(messageInputDomain.command.ClearCommand())
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEmojiSelect = (emoji: string) => {
|
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
send(messageInputDomain.command.InputCommand(`${message}${emoji}`))
|
if (autoCompleteListShow && autoCompleteList.length) {
|
||||||
inputRef.current?.focus()
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||||
|
const length = autoCompleteList.length
|
||||||
|
const prevIndex = selectedUserIndex
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
const index = (prevIndex + 1) % length
|
||||||
|
setSelectedUserIndex(index)
|
||||||
|
virtuosoRef.current?.scrollIntoView({ index })
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
const index = (prevIndex - 1 + length) % length
|
||||||
|
setSelectedUserIndex(index)
|
||||||
|
virtuosoRef.current?.scrollIntoView({ index })
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['Escape', 'Backspace', 'Delete', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||||
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
|
const isDeleteAt = message.at(selectionStart - 1) === '@'
|
||||||
|
setAutoCompleteListShow(!isDeleteAt)
|
||||||
|
} else {
|
||||||
|
setAutoCompleteListShow(false)
|
||||||
|
}
|
||||||
|
setSelectedUserIndex(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Enter' && !(e.shiftKey || e.ctrlKey || e.altKey || e.metaKey)) {
|
||||||
|
if (isComposing.current) return
|
||||||
|
|
||||||
|
if (autoCompleteListShow && autoCompleteList.length) {
|
||||||
|
handleInjectAtSyntax(selectedUser.username)
|
||||||
|
} else {
|
||||||
|
handleSend()
|
||||||
|
}
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleInput = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const currentMessage = e.target.value
|
||||||
|
|
||||||
|
if (autoCompleteListShow) {
|
||||||
|
const target = e.target as HTMLTextAreaElement
|
||||||
|
if (target.value) {
|
||||||
|
const atIndex = target.value.lastIndexOf('@', selectionEnd - 1)
|
||||||
|
if (atIndex !== -1) {
|
||||||
|
const keyword = target.value.slice(atIndex + 1, selectionEnd)
|
||||||
|
setSearchNameKeyword(keyword)
|
||||||
|
setSelectedUserIndex(0)
|
||||||
|
virtuosoRef.current?.scrollIntoView({ index: 0 })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setAutoCompleteListShow(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = e.nativeEvent as InputEvent
|
||||||
|
|
||||||
|
if (event.data === '@' && autoCompleteList.length) {
|
||||||
|
setAutoCompleteListShow(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-calculate the offset after InputCommand
|
||||||
|
const start = selectionStart
|
||||||
|
const end = selectionStart + currentMessage.length - message.length
|
||||||
|
|
||||||
|
updateAtUserAtRecord(currentMessage, start, end, 0)
|
||||||
|
|
||||||
|
send(messageInputDomain.command.InputCommand(currentMessage))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInjectEmoji = (emoji: string) => {
|
||||||
|
const newMessage = `${message.slice(0, selectionEnd)}${emoji}${message.slice(selectionEnd)}`
|
||||||
|
|
||||||
|
// Pre-calculate the offset after InputCommand
|
||||||
|
const start = selectionStart
|
||||||
|
const end = selectionEnd + newMessage.length - message.length
|
||||||
|
|
||||||
|
updateAtUserAtRecord(newMessage, start, end, 0)
|
||||||
|
|
||||||
|
send(messageInputDomain.command.InputCommand(newMessage))
|
||||||
|
|
||||||
|
requestIdleCallback(() => {
|
||||||
|
inputRef.current?.setSelectionRange(end, end)
|
||||||
|
inputRef.current?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInjectAtSyntax = (username: string) => {
|
||||||
|
const atIndex = message.lastIndexOf('@', selectionEnd - 1)
|
||||||
|
// Determine if there is a space before @
|
||||||
|
const hasBeforeSpace = message.slice(atIndex - 1, atIndex) === ' '
|
||||||
|
const hasAfterSpace = message.slice(selectionEnd, selectionEnd + 1) === ' '
|
||||||
|
|
||||||
|
const atText = `${hasBeforeSpace ? '' : ' '}@${username}${hasAfterSpace ? '' : ' '}`
|
||||||
|
const newMessage = message.slice(0, atIndex) + `${atText}` + message.slice(selectionEnd)
|
||||||
|
|
||||||
|
setAutoCompleteListShow(false)
|
||||||
|
|
||||||
|
// Pre-calculate the offset after InputCommand
|
||||||
|
const start = atIndex
|
||||||
|
const end = selectionStart + newMessage.length - message.length
|
||||||
|
|
||||||
|
const atUserPosition: [number, number] = [start + (hasBeforeSpace ? 0 : +1), end - 1 + (hasAfterSpace ? 0 : -1)]
|
||||||
|
|
||||||
|
// Calculate the difference after replacing @text with @user
|
||||||
|
const offset = newMessage.length - message.length - (atUserPosition[1] - atUserPosition[0])
|
||||||
|
|
||||||
|
updateAtUserAtRecord(newMessage, ...atUserPosition, offset, selectedUser.userId)
|
||||||
|
|
||||||
|
send(messageInputDomain.command.InputCommand(newMessage))
|
||||||
|
requestIdleCallback(() => {
|
||||||
|
inputRef.current!.setSelectionRange(end, end)
|
||||||
|
inputRef.current!.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = getRootNode()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative z-10 grid gap-y-2 px-4 pb-4 pt-2 before:pointer-events-none before:absolute before:inset-x-4 before:-top-2 before:h-2 before:bg-gradient-to-t before:from-slate-50 before:from-30% before:to-transparent">
|
<div className="relative z-10 grid gap-y-2 rounded-b-xl px-4 pb-4 pt-2 before:pointer-events-none before:absolute before:inset-x-4 before:-top-2 before:h-2 before:bg-gradient-to-t before:from-slate-50 before:from-30% before:to-transparent dark:bg-slate-900 before:dark:from-slate-900">
|
||||||
|
<Presence present={autoCompleteListShow}>
|
||||||
|
<Portal
|
||||||
|
container={root}
|
||||||
|
ref={shareAutoCompleteListRef}
|
||||||
|
className="fixed z-infinity w-36 -translate-y-full overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-md duration-300 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
|
||||||
|
style={{ left: `min(${x}px, 100vw - 160px)`, top: `${y}px` }}
|
||||||
|
>
|
||||||
|
<ScrollArea className="max-h-[204px] min-h-9 p-1" ref={setScrollParentRef}>
|
||||||
|
<Virtuoso
|
||||||
|
ref={virtuosoRef}
|
||||||
|
data={autoCompleteList}
|
||||||
|
defaultItemHeight={28}
|
||||||
|
context={{ currentItemIndex: selectedUserIndex }}
|
||||||
|
customScrollParent={scrollParentRef!}
|
||||||
|
itemContent={(index, user) => (
|
||||||
|
<div
|
||||||
|
key={user.userId}
|
||||||
|
onClick={() => handleInjectAtSyntax(user.username)}
|
||||||
|
onMouseEnter={() => setSelectedUserIndex(index)}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-pointer select-none items-center gap-x-2 rounded-md px-2 py-1.5 outline-none',
|
||||||
|
{
|
||||||
|
'bg-accent text-accent-foreground': index === selectedUserIndex
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Avatar className="size-4 shrink-0">
|
||||||
|
<AvatarImage className="size-full" src={user.userAvatar} alt="avatar" />
|
||||||
|
<AvatarFallback>{user.username.at(0)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 truncate text-xs text-slate-500">{user.username}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
></Virtuoso>
|
||||||
|
</ScrollArea>
|
||||||
|
</Portal>
|
||||||
|
</Presence>
|
||||||
<MessageInput
|
<MessageInput
|
||||||
ref={inputRef}
|
ref={shareRef}
|
||||||
value={message}
|
value={message}
|
||||||
onEnter={handleSend}
|
|
||||||
onInput={handleInput}
|
onInput={handleInput}
|
||||||
onCompositionEnd={() => (isComposing.current = false)}
|
onKeyDown={handleKeyDown}
|
||||||
onCompositionStart={() => (isComposing.current = true)}
|
|
||||||
maxLength={MESSAGE_MAX_LENGTH}
|
maxLength={MESSAGE_MAX_LENGTH}
|
||||||
></MessageInput>
|
></MessageInput>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<EmojiButton onSelect={handleEmojiSelect}></EmojiButton>
|
<EmojiButton onSelect={handleInjectEmoji}></EmojiButton>
|
||||||
{/* <Button variant="ghost" size="icon">
|
|
||||||
<ImageIcon size={20} />
|
|
||||||
</Button> */}
|
|
||||||
<Button className="ml-auto" size="sm" onClick={handleSend}>
|
<Button className="ml-auto" size="sm" onClick={handleSend}>
|
||||||
<span className="mr-2">Send</span>
|
<span className="mr-2">Send</span>
|
||||||
<CornerDownLeftIcon className="text-slate-400" size={12}></CornerDownLeftIcon>
|
<CornerDownLeftIcon className="text-slate-400" size={12}></CornerDownLeftIcon>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { type FC } from 'react'
|
import { useState, type FC } from 'react'
|
||||||
import { Globe2Icon } from 'lucide-react'
|
import { Globe2Icon } from 'lucide-react'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar'
|
||||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/HoverCard'
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/HoverCard'
|
||||||
|
@ -7,6 +7,7 @@ import { cn, getSiteInfo } from '@/utils'
|
||||||
import { useRemeshDomain, useRemeshQuery } from 'remesh-react'
|
import { useRemeshDomain, useRemeshQuery } from 'remesh-react'
|
||||||
import RoomDomain from '@/domain/Room'
|
import RoomDomain from '@/domain/Room'
|
||||||
import { ScrollArea } from '@/components/ui/ScrollArea'
|
import { ScrollArea } from '@/components/ui/ScrollArea'
|
||||||
|
import { Virtuoso } from 'react-virtuoso'
|
||||||
|
|
||||||
const Header: FC = () => {
|
const Header: FC = () => {
|
||||||
const siteInfo = getSiteInfo()
|
const siteInfo = getSiteInfo()
|
||||||
|
@ -14,8 +15,10 @@ const Header: FC = () => {
|
||||||
const userList = useRemeshQuery(roomDomain.query.UserListQuery())
|
const userList = useRemeshQuery(roomDomain.query.UserListQuery())
|
||||||
const onlineCount = userList.length
|
const onlineCount = userList.length
|
||||||
|
|
||||||
|
const [scrollParentRef, setScrollParentRef] = useState<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="z-10 grid h-12 grid-flow-col grid-cols-[theme('spacing.20')_auto_theme('spacing.20')] items-center justify-between rounded-t-xl bg-white px-4 backdrop-blur-lg">
|
<div className="z-10 grid h-12 grid-flow-col grid-cols-[theme('spacing.20')_auto_theme('spacing.20')] items-center justify-between rounded-t-xl bg-white px-4 backdrop-blur-lg dark:bg-slate-950">
|
||||||
<Avatar className="size-8">
|
<Avatar className="size-8">
|
||||||
<AvatarImage src={siteInfo.icon} alt="favicon" />
|
<AvatarImage src={siteInfo.icon} alt="favicon" />
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
|
@ -25,7 +28,7 @@ const Header: FC = () => {
|
||||||
<HoverCard>
|
<HoverCard>
|
||||||
<HoverCardTrigger asChild>
|
<HoverCardTrigger asChild>
|
||||||
<Button className="overflow-hidden p-2" variant="link">
|
<Button className="overflow-hidden p-2" variant="link">
|
||||||
<span className="truncate text-lg font-semibold text-slate-600">
|
<span className="truncate text-lg font-semibold text-slate-600 dark:text-slate-50">
|
||||||
{siteInfo.hostname.replace(/^www\./i, '')}
|
{siteInfo.hostname.replace(/^www\./i, '')}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -41,7 +44,9 @@ const Header: FC = () => {
|
||||||
<div className="grid items-center">
|
<div className="grid items-center">
|
||||||
<h4 className="truncate text-sm font-semibold">{siteInfo.title}</h4>
|
<h4 className="truncate text-sm font-semibold">{siteInfo.title}</h4>
|
||||||
{siteInfo.description && (
|
{siteInfo.description && (
|
||||||
<p className="line-clamp-2 max-h-8 text-xs text-slate-500">{siteInfo.description}</p>
|
<p className="line-clamp-2 max-h-8 text-xs text-slate-500 dark:text-slate-300">
|
||||||
|
{siteInfo.description}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -65,21 +70,26 @@ const Header: FC = () => {
|
||||||
)}
|
)}
|
||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
<span>ONLINE {onlineCount > 99 ? '99+' : onlineCount}</span>
|
<span className="dark:text-slate-50">ONLINE {onlineCount > 99 ? '99+' : onlineCount}</span>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</HoverCardTrigger>
|
</HoverCardTrigger>
|
||||||
<HoverCardContent className="w-44 rounded-lg px-0 py-2">
|
<HoverCardContent className="w-36 rounded-lg p-0">
|
||||||
<ScrollArea className="max-h-80">
|
<ScrollArea className="max-h-[204px] min-h-9 p-1" ref={setScrollParentRef}>
|
||||||
{userList.map((user) => (
|
<Virtuoso
|
||||||
<div className="flex items-center gap-x-2 px-4 py-2 [content-visibility:auto]" key={user.userId}>
|
data={userList}
|
||||||
<Avatar className="size-6 shrink-0">
|
defaultItemHeight={28}
|
||||||
<AvatarImage src={user.userAvatar} alt="avatar" />
|
customScrollParent={scrollParentRef!}
|
||||||
<AvatarFallback>{user.username.at(0)}</AvatarFallback>
|
itemContent={(index, user) => (
|
||||||
</Avatar>
|
<div className={cn('flex items-center gap-x-2 rounded-md px-2 py-1.5 outline-none')}>
|
||||||
<div className="flex-1 truncate text-sm text-slate-500">{user.username}</div>
|
<Avatar className="size-4 shrink-0">
|
||||||
</div>
|
<AvatarImage className="size-full" src={user.userAvatar} alt="avatar" />
|
||||||
))}
|
<AvatarFallback>{user.username.at(0)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 truncate text-xs text-slate-500 dark:text-slate-50">{user.username}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
></Virtuoso>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</HoverCardContent>
|
</HoverCardContent>
|
||||||
</HoverCard>
|
</HoverCard>
|
||||||
|
|
|
@ -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,24 +38,21 @@ 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}
|
like={message.like}
|
||||||
like={message.like}
|
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>
|
||||||
|
|
|
@ -43,7 +43,8 @@ const generateUserInfo = async (): Promise<UserInfo> => {
|
||||||
createTime: Date.now(),
|
createTime: Date.now(),
|
||||||
themeMode: checkSystemDarkMode() ? 'dark' : 'system',
|
themeMode: checkSystemDarkMode() ? 'dark' : 'system',
|
||||||
danmakuEnabled: true,
|
danmakuEnabled: true,
|
||||||
notificationEnabled: false
|
notificationEnabled: true,
|
||||||
|
notificationType: 'all'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -58,7 +59,8 @@ const generateMessage = async (userInfo: UserInfo): Promise<Message> => {
|
||||||
username,
|
username,
|
||||||
userAvatar,
|
userAvatar,
|
||||||
likeUsers: mockTextList.length ? [] : [{ userId, username, userAvatar }],
|
likeUsers: mockTextList.length ? [] : [{ userId, username, userAvatar }],
|
||||||
hateUsers: []
|
hateUsers: [],
|
||||||
|
atUsers: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -68,6 +70,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())
|
||||||
|
|
|
@ -4,17 +4,23 @@ import ProfileForm from './components/ProfileForm'
|
||||||
import BadgeList from './components/BadgeList'
|
import BadgeList from './components/BadgeList'
|
||||||
import Layout from './components/Layout'
|
import Layout from './components/Layout'
|
||||||
import VersionLink from './components/VersionLink'
|
import VersionLink from './components/VersionLink'
|
||||||
|
import { useRemeshDomain, useRemeshQuery } from 'remesh-react'
|
||||||
|
import UserInfoDomain from '@/domain/UserInfo'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||||
|
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<div className={userInfo?.themeMode}>
|
||||||
<VersionLink></VersionLink>
|
<Layout>
|
||||||
<Main>
|
<VersionLink></VersionLink>
|
||||||
<ProfileForm></ProfileForm>
|
<Main>
|
||||||
<Toaster richColors position="top-center" />
|
<ProfileForm></ProfileForm>
|
||||||
</Main>
|
<Toaster richColors position="top-center" duration={1000000} />
|
||||||
<BadgeList></BadgeList>
|
</Main>
|
||||||
</Layout>
|
<BadgeList></BadgeList>
|
||||||
|
</Layout>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,7 @@ export interface LayoutProps {
|
||||||
|
|
||||||
const Layout: FC<LayoutProps> = ({ children }) => {
|
const Layout: FC<LayoutProps> = ({ children }) => {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-screen bg-gray-50 bg-[url(@/assets/images/texture.png)] font-sans">
|
<div className={`h-screen w-screen bg-gray-50 bg-[url(@/assets/images/texture.png)] font-sans dark:bg-slate-950`}>
|
||||||
<div className="fixed left-0 top-0 h-full w-screen overflow-hidden">
|
<div className="fixed left-0 top-0 h-full w-screen overflow-hidden">
|
||||||
<Meteors number={30} />
|
<Meteors number={30} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -7,7 +7,7 @@ export interface MainProps {
|
||||||
const Main: FC<MainProps> = ({ children }) => {
|
const Main: FC<MainProps> = ({ children }) => {
|
||||||
return (
|
return (
|
||||||
<main className="grid min-h-screen min-w-screen items-center justify-center">
|
<main className="grid min-h-screen min-w-screen items-center justify-center">
|
||||||
<div className="relative rounded-xl bg-slate-50 shadow-lg">{children}</div>
|
<div className="relative rounded-xl bg-slate-50 shadow-lg dark:bg-slate-900 dark:text-slate-50">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,20 +3,20 @@ import { useForm } from 'react-hook-form'
|
||||||
import { valibotResolver } from '@hookform/resolvers/valibot'
|
import { valibotResolver } from '@hookform/resolvers/valibot'
|
||||||
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
import { useEffect } from 'react'
|
import { ReactNode, useEffect, type FC } from 'react'
|
||||||
import AvatarSelect from './AvatarSelect'
|
import AvatarSelect from './AvatarSelect'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/Form'
|
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/Form'
|
||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
import UserInfoDomain, { type UserInfo } from '@/domain/UserInfo'
|
import UserInfoDomain, { type UserInfo } from '@/domain/UserInfo'
|
||||||
import { checkSystemDarkMode, generateRandomAvatar } from '@/utils'
|
import { checkSystemDarkMode, cn, generateRandomAvatar } from '@/utils'
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup'
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup'
|
||||||
import { Label } from '@/components/ui/Label'
|
import { Label } from '@/components/ui/Label'
|
||||||
import { RefreshCcwIcon } from 'lucide-react'
|
import { RefreshCcwIcon } from 'lucide-react'
|
||||||
import { MAX_AVATAR_SIZE } from '@/constants/config'
|
import { MAX_AVATAR_SIZE } from '@/constants/config'
|
||||||
import { ToastImpl } from '@/domain/impls/Toast'
|
import { ToastImpl } from '@/domain/impls/Toast'
|
||||||
import BlurFade from '@/components/magicui/BlurFade'
|
import BlurFade from '@/components/magicui/BlurFade'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/Checkbox'
|
||||||
import Link from '@/components/Link'
|
import Link from '@/components/Link'
|
||||||
|
|
||||||
const defaultUserInfo: UserInfo = {
|
const defaultUserInfo: UserInfo = {
|
||||||
|
@ -26,7 +26,8 @@ const defaultUserInfo: UserInfo = {
|
||||||
createTime: Date.now(),
|
createTime: Date.now(),
|
||||||
themeMode: checkSystemDarkMode() ? 'dark' : 'system',
|
themeMode: checkSystemDarkMode() ? 'dark' : 'system',
|
||||||
danmakuEnabled: true,
|
danmakuEnabled: true,
|
||||||
notificationEnabled: false
|
notificationEnabled: true,
|
||||||
|
notificationType: 'all'
|
||||||
}
|
}
|
||||||
|
|
||||||
const formSchema = v.object({
|
const formSchema = v.object({
|
||||||
|
@ -34,13 +35,9 @@ const formSchema = v.object({
|
||||||
createTime: v.number(),
|
createTime: v.number(),
|
||||||
// Pure numeric strings will be converted to number
|
// Pure numeric strings will be converted to number
|
||||||
// Issues: https://github.com/unjs/unstorage/issues/277
|
// Issues: https://github.com/unjs/unstorage/issues/277
|
||||||
// name: v.string([
|
|
||||||
// // toTrimmed(),
|
|
||||||
// v.minBytes(1, 'Please enter your username.'),
|
|
||||||
// v.maxBytes(20, 'Your username cannot exceed 20 bytes.')
|
|
||||||
// ]),
|
|
||||||
name: v.pipe(
|
name: v.pipe(
|
||||||
v.string(),
|
v.string(),
|
||||||
|
v.trim(),
|
||||||
v.minBytes(1, 'Please enter your username.'),
|
v.minBytes(1, 'Please enter your username.'),
|
||||||
v.maxBytes(20, 'Your username cannot exceed 20 bytes.')
|
v.maxBytes(20, 'Your username cannot exceed 20 bytes.')
|
||||||
),
|
),
|
||||||
|
@ -54,10 +51,10 @@ const formSchema = v.object({
|
||||||
v.union([v.literal('system'), v.literal('light'), v.literal('dark')], 'Please select extension theme mode.')
|
v.union([v.literal('system'), v.literal('light'), v.literal('dark')], 'Please select extension theme mode.')
|
||||||
),
|
),
|
||||||
danmakuEnabled: v.boolean(),
|
danmakuEnabled: v.boolean(),
|
||||||
notificationEnabled: v.boolean()
|
notificationEnabled: v.boolean(),
|
||||||
|
notificationType: v.pipe(v.string(), v.union([v.literal('all'), v.literal('at')], 'Please select notification type.'))
|
||||||
})
|
})
|
||||||
|
const ProfileForm: FC = () => {
|
||||||
const ProfileForm = () => {
|
|
||||||
const send = useRemeshSend()
|
const send = useRemeshSend()
|
||||||
const toast = ToastImpl.value
|
const toast = ToastImpl.value
|
||||||
|
|
||||||
|
@ -103,7 +100,7 @@ const ProfileForm = () => {
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="avatar"
|
name="avatar"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="absolute inset-x-1 top-0 mx-auto grid w-fit -translate-y-1/3 justify-items-center">
|
<FormItem className="absolute inset-x-1 top-0 mx-auto grid w-fit -translate-y-1/3 justify-items-center">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<BlurFade key={form.getValues().avatar} duration={0.1}>
|
<BlurFade key={form.getValues().avatar} duration={0.1}>
|
||||||
|
@ -136,7 +133,7 @@ const ProfileForm = () => {
|
||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Username</FormLabel>
|
<FormLabel className="font-semibold">Username</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="Please enter your username" {...field} />
|
<Input placeholder="Please enter your username" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
@ -150,7 +147,6 @@ const ProfileForm = () => {
|
||||||
name="danmakuEnabled"
|
name="danmakuEnabled"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
{/* <FormLabel>Username</FormLabel> */}
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
@ -159,7 +155,7 @@ const ProfileForm = () => {
|
||||||
onCheckedChange={field.onChange}
|
onCheckedChange={field.onChange}
|
||||||
checked={field.value}
|
checked={field.value}
|
||||||
/>
|
/>
|
||||||
<FormLabel className="cursor-pointer" htmlFor="enable-danmaku">
|
<FormLabel className="cursor-pointer font-semibold" htmlFor="enable-danmaku">
|
||||||
Enable Danmaku
|
Enable Danmaku
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
</div>
|
</div>
|
||||||
|
@ -174,24 +170,66 @@ const ProfileForm = () => {
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="notificationEnabled"
|
name="notificationType"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
{/* <FormLabel>Username</FormLabel> */}
|
<FormField
|
||||||
<FormControl>
|
control={form.control}
|
||||||
<div className="flex items-center space-x-2">
|
name="notificationEnabled"
|
||||||
<Checkbox
|
render={({ field }) => (
|
||||||
defaultChecked={false}
|
<FormItem>
|
||||||
id="notification-enabled"
|
<FormControl>
|
||||||
onCheckedChange={field.onChange}
|
<div className="flex items-center space-x-2">
|
||||||
checked={field.value}
|
<Checkbox
|
||||||
/>
|
defaultChecked={false}
|
||||||
<FormLabel className="cursor-pointer" htmlFor="notification-enabled">
|
id="enable-notification"
|
||||||
Enable Notification
|
onCheckedChange={field.onChange}
|
||||||
</FormLabel>
|
checked={field.value}
|
||||||
</div>
|
/>
|
||||||
|
<FormLabel className="cursor-pointer font-semibold" htmlFor="enable-notification">
|
||||||
|
Enable Notification
|
||||||
|
</FormLabel>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormControl className="pl-6">
|
||||||
|
<RadioGroup
|
||||||
|
disabled={!form.getValues('notificationEnabled')}
|
||||||
|
className="flex gap-x-4"
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
value={field.value}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="all" id="all" />
|
||||||
|
<Label
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer',
|
||||||
|
!form.getValues('notificationEnabled') && 'cursor-not-allowed opacity-50'
|
||||||
|
)}
|
||||||
|
htmlFor="all"
|
||||||
|
>
|
||||||
|
All message
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="at" id="at" />
|
||||||
|
<Label
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer',
|
||||||
|
!form.getValues('notificationEnabled') && 'cursor-not-allowed opacity-50'
|
||||||
|
)}
|
||||||
|
htmlFor="at"
|
||||||
|
>
|
||||||
|
Only @self
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Enabling this option will display desktop notifications for messages.</FormDescription>
|
<FormDescription>Enabling this option will display desktop notifications for messages.</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
@ -203,20 +241,26 @@ const ProfileForm = () => {
|
||||||
name="themeMode"
|
name="themeMode"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Theme Mode</FormLabel>
|
<FormLabel className="font-semibold">Theme Mode</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<RadioGroup className="flex gap-x-4" onValueChange={field.onChange} value={field.value}>
|
<RadioGroup className="flex gap-x-4" onValueChange={field.onChange} value={field.value}>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="system" id="r1" />
|
<RadioGroupItem value="system" id="system" />
|
||||||
<Label htmlFor="r1">System</Label>
|
<Label className="cursor-pointer" htmlFor="system">
|
||||||
|
System
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="light" id="r2" />
|
<RadioGroupItem value="light" id="light" />
|
||||||
<Label htmlFor="r2">Light</Label>
|
<Label className="cursor-pointer" htmlFor="light">
|
||||||
|
Light
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="dark" id="r3" />
|
<RadioGroupItem value="dark" id="dark" />
|
||||||
<Label htmlFor="r3">Dark</Label>
|
<Label className="cursor-pointer" htmlFor="dark">
|
||||||
|
Dark
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
|
@ -673,12 +673,15 @@ section:has([data-sonner-toaster]) {
|
||||||
|
|
||||||
/* Custom styles */
|
/* Custom styles */
|
||||||
:where([data-sonner-toaster]) {
|
:where([data-sonner-toaster]) {
|
||||||
width: 200px;
|
max-width: 300px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
:where([data-sonner-toast][data-styled='true']) {
|
:where([data-sonner-toast][data-styled='true']) {
|
||||||
width: 200px;
|
max-width: 300px;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
|
@ -81,11 +81,10 @@
|
||||||
all: initial !important;
|
all: initial !important;
|
||||||
direction: ltr !important;
|
direction: ltr !important;
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* Fix: scroll area dispay: table
|
|
||||||
* @see https://github.com/radix-ui/primitives/issues/3129
|
|
||||||
*/
|
|
||||||
[data-radix-scroll-area-viewport] > div {
|
|
||||||
display: block !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* @property --shimmer-angle {
|
||||||
|
syntax: '<angle>';
|
||||||
|
inherits: false;
|
||||||
|
initial-value: 0deg;
|
||||||
|
} */
|
||||||
|
|
|
@ -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,14 +106,14 @@ 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>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
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 dark:text-slate-50')}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
|
|
|
@ -29,7 +29,7 @@ const AvatarFallback = React.forwardRef<
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<AvatarPrimitive.Fallback
|
<AvatarPrimitive.Fallback
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn('flex h-full w-full items-center justify-center rounded-full bg-muted', className)}
|
className={cn('flex h-full w-full items-center justify-center rounded-full bg-muted dark:text-slate-400', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||||
import { cn } from '@/utils/index'
|
import { cn, getRootNode } from '@/utils'
|
||||||
|
|
||||||
const Popover = PopoverPrimitive.Root
|
const Popover = PopoverPrimitive.Root
|
||||||
|
|
||||||
|
@ -10,9 +10,9 @@ const PopoverContent = React.forwardRef<
|
||||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => {
|
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => {
|
||||||
const shadowRoot = document.querySelector(__NAME__)!.shadowRoot! as unknown as HTMLElement
|
const root = getRootNode()
|
||||||
return (
|
return (
|
||||||
<PopoverPrimitive.Portal container={shadowRoot}>
|
<PopoverPrimitive.Portal container={root}>
|
||||||
<PopoverPrimitive.Content
|
<PopoverPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
align={align}
|
align={align}
|
||||||
|
|
|
@ -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 />
|
||||||
|
|
|
@ -8,7 +8,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ classNa
|
||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex min-h-[60px] w-full rounded-md border border-input text-primary bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
'flex min-h-[60px] w-full rounded-md border border-input text-primary bg-transparent p-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import * as React from "react"
|
import * as React from 'react'
|
||||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
|
||||||
import { CheckIcon } from "@radix-ui/react-icons"
|
import { CheckIcon } from '@radix-ui/react-icons'
|
||||||
|
|
||||||
import { cn } from "@/utils/index"
|
import { cn } from '@/utils/index'
|
||||||
|
|
||||||
const Checkbox = React.forwardRef<
|
const Checkbox = React.forwardRef<
|
||||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||||
|
@ -11,14 +11,12 @@ const Checkbox = React.forwardRef<
|
||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
'peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<CheckboxPrimitive.Indicator
|
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
|
||||||
className={cn("flex items-center justify-center text-current")}
|
|
||||||
>
|
|
||||||
<CheckIcon className="h-4 w-4" />
|
<CheckIcon className="h-4 w-4" />
|
||||||
</CheckboxPrimitive.Indicator>
|
</CheckboxPrimitive.Indicator>
|
||||||
</CheckboxPrimitive.Root>
|
</CheckboxPrimitive.Root>
|
||||||
|
|
|
@ -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 - 50, 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',
|
||||||
|
|
|
@ -16,6 +16,10 @@ export interface MessageUser {
|
||||||
userAvatar: string
|
userAvatar: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AtUser extends MessageUser {
|
||||||
|
positions: [number, number][]
|
||||||
|
}
|
||||||
|
|
||||||
export interface NormalMessage extends MessageUser {
|
export interface NormalMessage extends MessageUser {
|
||||||
type: MessageType.Normal
|
type: MessageType.Normal
|
||||||
id: string
|
id: string
|
||||||
|
@ -23,6 +27,7 @@ export interface NormalMessage extends MessageUser {
|
||||||
date: number
|
date: number
|
||||||
likeUsers: MessageUser[]
|
likeUsers: MessageUser[]
|
||||||
hateUsers: MessageUser[]
|
hateUsers: MessageUser[]
|
||||||
|
atUsers: AtUser[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromptMessage extends MessageUser {
|
export interface PromptMessage extends MessageUser {
|
||||||
|
|
|
@ -69,11 +69,27 @@ const NotificationDomain = Remesh.domain({
|
||||||
name: 'Notification.OnRoomMessageEffect',
|
name: 'Notification.OnRoomMessageEffect',
|
||||||
impl: ({ fromEvent, get }) => {
|
impl: ({ fromEvent, get }) => {
|
||||||
const onTextMessage$ = fromEvent(roomDomain.event.OnTextMessageEvent)
|
const onTextMessage$ = fromEvent(roomDomain.event.OnTextMessageEvent)
|
||||||
|
|
||||||
const onMessage$ = merge(onTextMessage$).pipe(
|
const onMessage$ = merge(onTextMessage$).pipe(
|
||||||
map((message) => {
|
map((message) => {
|
||||||
const notificationEnabled = get(IsEnabledQuery())
|
const notificationEnabled = get(IsEnabledQuery())
|
||||||
return notificationEnabled ? PushCommand(message) : null
|
if (notificationEnabled) {
|
||||||
|
// Compatible with old versions, without the atUsers field
|
||||||
|
if (message.atUsers) {
|
||||||
|
const userInfo = get(userInfoDomain.query.UserInfoQuery())
|
||||||
|
const hasAtSelf = message.atUsers.find((user) => user.userId === userInfo?.id)
|
||||||
|
if (userInfo?.notificationType === 'all') {
|
||||||
|
return PushCommand(message)
|
||||||
|
}
|
||||||
|
if (userInfo?.notificationType === 'at' && hasAtSelf) {
|
||||||
|
return PushCommand(message)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} else {
|
||||||
|
return PushCommand(message)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Remesh } from 'remesh'
|
import { Remesh } from 'remesh'
|
||||||
import { map, merge, of, EMPTY, mergeMap, fromEvent, fromEventPattern } from 'rxjs'
|
import { map, merge, of, EMPTY, mergeMap, fromEvent, fromEventPattern } from 'rxjs'
|
||||||
import { NormalMessage, type MessageUser } from './MessageList'
|
import { AtUser, NormalMessage, type MessageUser } from './MessageList'
|
||||||
import { PeerRoomExtern } from '@/domain/externs/PeerRoom'
|
import { PeerRoomExtern } from '@/domain/externs/PeerRoom'
|
||||||
import MessageListDomain, { MessageType } from '@/domain/MessageList'
|
import MessageListDomain, { MessageType } from '@/domain/MessageList'
|
||||||
import UserInfoDomain from '@/domain/UserInfo'
|
import UserInfoDomain from '@/domain/UserInfo'
|
||||||
|
@ -38,6 +38,7 @@ export interface TextMessage extends MessageUser {
|
||||||
type: SendType.Text
|
type: SendType.Text
|
||||||
id: string
|
id: string
|
||||||
body: string
|
body: string
|
||||||
|
atUsers: AtUser[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RoomMessage = SyncUserMessage | LikeMessage | HateMessage | TextMessage
|
export type RoomMessage = SyncUserMessage | LikeMessage | HateMessage | TextMessage
|
||||||
|
@ -134,16 +135,17 @@ const RoomDomain = Remesh.domain({
|
||||||
|
|
||||||
const SendTextMessageCommand = domain.command({
|
const SendTextMessageCommand = domain.command({
|
||||||
name: 'Room.SendTextMessageCommand',
|
name: 'Room.SendTextMessageCommand',
|
||||||
impl: ({ get }, message: string) => {
|
impl: ({ get }, message: string | { body: string; atUsers: AtUser[] }) => {
|
||||||
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
|
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||||
|
|
||||||
const textMessage: TextMessage = {
|
const textMessage: TextMessage = {
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
type: SendType.Text,
|
type: SendType.Text,
|
||||||
body: message,
|
body: typeof message === 'string' ? message : message.body,
|
||||||
userId,
|
userId,
|
||||||
username,
|
username,
|
||||||
userAvatar
|
userAvatar,
|
||||||
|
atUsers: typeof message === 'string' ? [] : message.atUsers
|
||||||
}
|
}
|
||||||
|
|
||||||
const listMessage: NormalMessage = {
|
const listMessage: NormalMessage = {
|
||||||
|
@ -151,7 +153,8 @@ const RoomDomain = Remesh.domain({
|
||||||
type: MessageType.Normal,
|
type: MessageType.Normal,
|
||||||
date: Date.now(),
|
date: Date.now(),
|
||||||
likeUsers: [],
|
likeUsers: [],
|
||||||
hateUsers: []
|
hateUsers: [],
|
||||||
|
atUsers: typeof message === 'string' ? [] : message.atUsers
|
||||||
}
|
}
|
||||||
|
|
||||||
peerRoom.sendMessage(textMessage)
|
peerRoom.sendMessage(textMessage)
|
||||||
|
|
|
@ -12,6 +12,7 @@ export interface UserInfo {
|
||||||
themeMode: 'system' | 'light' | 'dark'
|
themeMode: 'system' | 'light' | 'dark'
|
||||||
danmakuEnabled: boolean
|
danmakuEnabled: boolean
|
||||||
notificationEnabled: boolean
|
notificationEnabled: boolean
|
||||||
|
notificationType: 'all' | 'at'
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserInfoDomain = Remesh.domain({
|
const UserInfoDomain = Remesh.domain({
|
||||||
|
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
@ -1,51 +0,0 @@
|
||||||
import { type RefObject, useEffect, useRef } from 'react'
|
|
||||||
|
|
||||||
export type Events = Array<keyof GlobalEventHandlersEventMap>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Waiting for PR merge
|
|
||||||
* @see https://github.com/streamich/react-use/pull/2528
|
|
||||||
*/
|
|
||||||
const useClickAway = <E extends Event = Event>(
|
|
||||||
ref: RefObject<HTMLElement | null>,
|
|
||||||
onClickAway: (event: E) => void,
|
|
||||||
events: Events = ['mousedown', 'touchstart']
|
|
||||||
) => {
|
|
||||||
const savedCallback = useRef(onClickAway)
|
|
||||||
useEffect(() => {
|
|
||||||
savedCallback.current = onClickAway
|
|
||||||
}, [onClickAway])
|
|
||||||
useEffect(() => {
|
|
||||||
const { current: el } = ref
|
|
||||||
if (!el) return
|
|
||||||
|
|
||||||
const rootNode = el.getRootNode()
|
|
||||||
const isInShadow = rootNode instanceof ShadowRoot
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When events are captured outside the component, events that occur in shadow DOM will target the host element
|
|
||||||
* so additional event listeners need to be added for shadowDom
|
|
||||||
*
|
|
||||||
* document shadowDom target
|
|
||||||
* | | |
|
|
||||||
* |- on(document) -|- on(shadowRoot) -|
|
|
||||||
*/
|
|
||||||
const handler = (event: SafeAny) => {
|
|
||||||
!el.contains(event.target) && event.target.shadowRoot !== rootNode && savedCallback.current(event)
|
|
||||||
}
|
|
||||||
for (const eventName of events) {
|
|
||||||
// eslint-disable-next-line @eslint-react/web-api/no-leaked-event-listener
|
|
||||||
document.addEventListener(eventName, handler)
|
|
||||||
// eslint-disable-next-line @eslint-react/web-api/no-leaked-event-listener
|
|
||||||
isInShadow && rootNode.addEventListener(eventName, handler)
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
for (const eventName of events) {
|
|
||||||
document.removeEventListener(eventName, handler)
|
|
||||||
isInShadow && rootNode.removeEventListener(eventName, handler)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [events, ref])
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useClickAway
|
|
41
src/hooks/useCursorPosition.ts
Normal file
41
src/hooks/useCursorPosition.ts
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import { RefCallback, useCallback, useRef, useState } from 'react'
|
||||||
|
import getCursorPosition, { Position } from '@/utils/getCursorPosition'
|
||||||
|
|
||||||
|
const useCursorPosition = () => {
|
||||||
|
const [position, setPosition] = useState<Position>({ x: 0, y: 0, selectionStart: 0, selectionEnd: 0 })
|
||||||
|
|
||||||
|
const handler = async (e: Event) => {
|
||||||
|
const newPosition = await getCursorPosition(e.target as HTMLInputElement | HTMLTextAreaElement)
|
||||||
|
if (JSON.stringify(newPosition) !== JSON.stringify(position)) {
|
||||||
|
setPosition(newPosition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null)
|
||||||
|
|
||||||
|
const setRef: RefCallback<HTMLInputElement | HTMLTextAreaElement | null> = useCallback(
|
||||||
|
(node) => {
|
||||||
|
if (handleRef.current) {
|
||||||
|
handleRef.current.removeEventListener('click', handler)
|
||||||
|
handleRef.current.removeEventListener('input', handler)
|
||||||
|
handleRef.current.removeEventListener('keydown', handler)
|
||||||
|
handleRef.current.removeEventListener('keyup', handler)
|
||||||
|
}
|
||||||
|
if (node) {
|
||||||
|
node.addEventListener('click', handler)
|
||||||
|
node.addEventListener('input', handler)
|
||||||
|
node.addEventListener('keydown', handler)
|
||||||
|
node.addEventListener('keyup', handler)
|
||||||
|
}
|
||||||
|
handleRef.current = node
|
||||||
|
},
|
||||||
|
[handler]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...position,
|
||||||
|
setRef
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useCursorPosition
|
94
src/hooks/useDarg.ts
Normal file
94
src/hooks/useDarg.ts
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
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(() => {
|
||||||
|
const newPosition = { x: clamp(initX, minX, maxX), y: clamp(initY, minY, maxY) }
|
||||||
|
if (JSON.stringify(newPosition) !== JSON.stringify(position)) {
|
||||||
|
setPosition(newPosition)
|
||||||
|
}
|
||||||
|
}, [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 setRef = 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 { setRef, ...position }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useDarg
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useRef, useState } from 'react'
|
import { RefCallback, 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,14 @@ 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(() => {
|
||||||
|
const newSize = clamp(initSize, minSize, maxSize)
|
||||||
|
if (newSize !== size) {
|
||||||
|
setSize(newSize)
|
||||||
|
}
|
||||||
|
}, [initSize, minSize, maxSize])
|
||||||
|
|
||||||
const position = useRef(0)
|
const position = useRef(0)
|
||||||
|
|
||||||
|
@ -67,13 +74,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 setRef: RefCallback<HTMLElement | null> = useCallback(
|
||||||
(node: HTMLElement | null) => {
|
(node) => {
|
||||||
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 +89,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, setRef }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useResizable
|
export default useResizable
|
||||||
|
|
21
src/hooks/useShareRef.ts
Normal file
21
src/hooks/useShareRef.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import { ForwardedRef, MutableRefObject, RefCallback, useCallback } from 'react'
|
||||||
|
|
||||||
|
const useShareRef = <T extends HTMLElement | null>(
|
||||||
|
...refs: (MutableRefObject<T> | ForwardedRef<T> | RefCallback<T>)[]
|
||||||
|
) => {
|
||||||
|
const setRef = useCallback(
|
||||||
|
(node: T) =>
|
||||||
|
refs.forEach((ref) => {
|
||||||
|
if (typeof ref === 'function') {
|
||||||
|
ref(node)
|
||||||
|
} else if (ref) {
|
||||||
|
ref.current = node
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[...refs]
|
||||||
|
)
|
||||||
|
|
||||||
|
return setRef
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useShareRef
|
52
src/hooks/useTriggerAway.ts
Normal file
52
src/hooks/useTriggerAway.ts
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import { RefCallback, useCallback, useRef } from 'react'
|
||||||
|
|
||||||
|
export type Events = Array<keyof GlobalEventHandlersEventMap>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see https://github.com/streamich/react-use/pull/2528
|
||||||
|
*/
|
||||||
|
const useTriggerAway = <E extends Event = Event>(events: Events, callback: (event: E) => void) => {
|
||||||
|
const handleRef = useRef<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const handler = (event: SafeAny) => {
|
||||||
|
const rootNode = handleRef.current?.getRootNode()
|
||||||
|
!handleRef.current?.contains(event.target) && event.target.shadowRoot !== rootNode && callback(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When events are captured outside the component, events that occur in shadow DOM will target the host element
|
||||||
|
* so additional event listeners need to be added for shadowDom
|
||||||
|
*
|
||||||
|
* document shadowDom target
|
||||||
|
* | | |
|
||||||
|
* |- on(document) -|- on(shadowRoot) -|
|
||||||
|
*/
|
||||||
|
const setRef: RefCallback<HTMLElement | null> = useCallback(
|
||||||
|
(node) => {
|
||||||
|
if (handleRef.current) {
|
||||||
|
const rootNode = handleRef.current.getRootNode()
|
||||||
|
const isInShadow = rootNode instanceof ShadowRoot
|
||||||
|
events.forEach(() => {
|
||||||
|
for (const eventName of events) {
|
||||||
|
document.removeEventListener(eventName, handler)
|
||||||
|
isInShadow && rootNode.removeEventListener(eventName, handler)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (node) {
|
||||||
|
const rootNode = node.getRootNode()
|
||||||
|
const isInShadow = rootNode instanceof ShadowRoot
|
||||||
|
events.forEach((eventName) => {
|
||||||
|
document.addEventListener(eventName, handler)
|
||||||
|
isInShadow && rootNode.addEventListener(eventName, handler)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
handleRef.current = node
|
||||||
|
},
|
||||||
|
[handler]
|
||||||
|
)
|
||||||
|
|
||||||
|
return { setRef }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useTriggerAway
|
22
src/hooks/useWindowResize.ts
Normal file
22
src/hooks/useWindowResize.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
const useWindowResize = (callback?: ({ width, height }: { width: number; height: number }) => void) => {
|
||||||
|
const [size, setSize] = useState({ width: window.innerWidth, height: window.innerHeight })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => {
|
||||||
|
const width = window.innerWidth
|
||||||
|
const height = window.innerHeight
|
||||||
|
setSize({ width, height })
|
||||||
|
callback?.({ width, height })
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', handler)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handler)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useWindowResize
|
74
src/utils/getCursorPosition.ts
Normal file
74
src/utils/getCursorPosition.ts
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
import { createElement } from '@/utils'
|
||||||
|
|
||||||
|
export interface Position {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
selectionStart: number
|
||||||
|
selectionEnd: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCursorPosition = (target: HTMLInputElement | HTMLTextAreaElement) => {
|
||||||
|
return new Promise<Position>((resolve, reject) =>
|
||||||
|
requestIdleCallback(() => {
|
||||||
|
try {
|
||||||
|
const value = target.value
|
||||||
|
|
||||||
|
const inputWrapper = createElement<HTMLDivElement>(
|
||||||
|
`<div style="position: fixed; z-index: calc(-infinity); width: 0; height: 0; overflow: hidden; visibility: hidden; pointer-events: none;"></div>`
|
||||||
|
// `<div id="input-wrapper" style="position: fixed"></div>`
|
||||||
|
)
|
||||||
|
const copyInput = createElement<HTMLDivElement>(`<div contenteditable></div>`)
|
||||||
|
|
||||||
|
inputWrapper.appendChild(copyInput)
|
||||||
|
target.ownerDocument.body.appendChild(inputWrapper)
|
||||||
|
|
||||||
|
const { left, top, width, height } = target.getBoundingClientRect()
|
||||||
|
|
||||||
|
const isEmptyOrBreakEnd = /(\n|\s*$)/.test(value)
|
||||||
|
copyInput.textContent = isEmptyOrBreakEnd ? `${value}\u200b` : value
|
||||||
|
|
||||||
|
const copyStyle = getComputedStyle(target)
|
||||||
|
|
||||||
|
for (const key of copyStyle) {
|
||||||
|
Reflect.set(copyInput.style, key, copyStyle[key as keyof CSSStyleDeclaration])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.tagName === 'INPUT') {
|
||||||
|
copyInput.style.lineHeight = copyStyle.height
|
||||||
|
}
|
||||||
|
|
||||||
|
copyInput.style.overflow = 'auto'
|
||||||
|
|
||||||
|
copyInput.style.width = `${width}px`
|
||||||
|
copyInput.style.height = `${height}px`
|
||||||
|
copyInput.style.boxSizing = 'border-box'
|
||||||
|
copyInput.style.margin = '0'
|
||||||
|
copyInput.style.position = 'fixed'
|
||||||
|
copyInput.style.top = `${top}px`
|
||||||
|
copyInput.style.left = `${left}px`
|
||||||
|
copyInput.style.pointerEvents = 'none'
|
||||||
|
|
||||||
|
// sync scroll
|
||||||
|
copyInput.scrollTop = target.scrollTop
|
||||||
|
copyInput.scrollLeft = target.scrollLeft
|
||||||
|
|
||||||
|
const selectionStart = target.selectionStart!
|
||||||
|
const selectionEnd = target.selectionEnd!
|
||||||
|
|
||||||
|
const range = new Range()
|
||||||
|
range.setStart(copyInput.childNodes[0], selectionStart)
|
||||||
|
range.setEnd(copyInput.childNodes[0], isEmptyOrBreakEnd ? selectionEnd + 1 : selectionEnd)
|
||||||
|
|
||||||
|
const { x, y } = range.getBoundingClientRect()
|
||||||
|
|
||||||
|
target.ownerDocument.body.removeChild(inputWrapper)
|
||||||
|
|
||||||
|
resolve({ x, y, selectionStart, selectionEnd })
|
||||||
|
} catch (error) {
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default getCursorPosition
|
5
src/utils/getRootNode.ts
Normal file
5
src/utils/getRootNode.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export const getRootNode = () => {
|
||||||
|
return document.querySelector(__NAME__)?.shadowRoot?.querySelector('#app') || document.body
|
||||||
|
}
|
||||||
|
|
||||||
|
export default getRootNode
|
45
src/utils/getTextSimilarity.ts
Normal file
45
src/utils/getTextSimilarity.ts
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
/**
|
||||||
|
* Calculates the length of the Longest Common Subsequence (LCS) between two strings.
|
||||||
|
* @param a - The first string.
|
||||||
|
* @param b - The second string.
|
||||||
|
* @returns The length of the longest common subsequence.
|
||||||
|
* @see https://en.wikipedia.org/wiki/Longest_common_subsequence
|
||||||
|
*/
|
||||||
|
const getTextLCS = (a: string, b: string): number => {
|
||||||
|
// Create a 2D array to store the lengths of longest common subsequences
|
||||||
|
const dp: number[][] = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0))
|
||||||
|
|
||||||
|
// Fill the dp array
|
||||||
|
for (let i = 1; i <= a.length; i++) {
|
||||||
|
for (let j = 1; j <= b.length; j++) {
|
||||||
|
// If characters match, increment the length of the LCS found so far
|
||||||
|
if (a[i - 1] === b[j - 1]) {
|
||||||
|
dp[i][j] = dp[i - 1][j - 1] + 1
|
||||||
|
} else {
|
||||||
|
// If characters do not match, take the maximum length from the previous computations
|
||||||
|
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The length of the longest common subsequence is found in the bottom-right cell of the dp array
|
||||||
|
return dp[a.length][b.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the similarity between two strings based on their longest common subsequence.
|
||||||
|
* @param a - The first string.
|
||||||
|
* @param b - The second string.
|
||||||
|
* @returns A number representing the similarity between the two strings (0 to 1).
|
||||||
|
*/
|
||||||
|
const getTextSimilarity = (a: string, b: string): number => {
|
||||||
|
// Get the length of the longest common subsequence
|
||||||
|
const lcsLength: number = getTextLCS(a, b)
|
||||||
|
// Get the maximum length of the two strings
|
||||||
|
const maxLength: number = Math.max(a.length, b.length)
|
||||||
|
|
||||||
|
// Calculate similarity based on the length of the LCS
|
||||||
|
return maxLength === 0 ? 0 : lcsLength / maxLength
|
||||||
|
}
|
||||||
|
|
||||||
|
export default getTextSimilarity
|
|
@ -11,3 +11,6 @@ export { default as throttle } from './throttle'
|
||||||
export { chunk, desert, upsert } from './array'
|
export { chunk, desert, upsert } from './array'
|
||||||
export { default as generateRandomAvatar } from './generateRandomAvatar'
|
export { default as generateRandomAvatar } from './generateRandomAvatar'
|
||||||
export { default as generateRandomName } from './generateRandomName'
|
export { default as generateRandomName } from './generateRandomName'
|
||||||
|
export { default as getCursorPosition } from './getCursorPosition'
|
||||||
|
export { default as getTextSimilarity } from './getTextSimilarity'
|
||||||
|
export { default as getRootNode } from './getRootNode'
|
||||||
|
|
|
@ -81,6 +81,14 @@ export default {
|
||||||
transform: 'rotate(215deg) translateX(-500px)',
|
transform: 'rotate(215deg) translateX(-500px)',
|
||||||
opacity: '0'
|
opacity: '0'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
shimmer: {
|
||||||
|
'0%': {
|
||||||
|
'--shimmer-angle': '0deg'
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
'--shimmer-angle': '360deg'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
|
|
Loading…
Reference in a new issue