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 wxt prepare
|
||||
- run: pnpm run lint
|
||||
- run: pnpm run tsc
|
||||
- run: pnpm run check
|
||||
|
||||
release:
|
||||
needs: linter
|
||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -14,4 +14,5 @@ web-ext.config.ts
|
|||
*.pem
|
||||
*.xpi
|
||||
*.zip
|
||||
.idea
|
||||
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
|
||||
pnpm lint-staged && pnpm tsc
|
||||
pnpm lint-staged && pnpm check
|
||||
|
|
|
@ -34,7 +34,8 @@ export default [
|
|||
'@typescript-eslint/no-unused-expressions': 'off',
|
||||
'@eslint-react/no-array-index-key': 'off',
|
||||
'@eslint-react/hooks-extra/no-redundant-custom-hook': 'off',
|
||||
'@eslint-react/dom/no-missing-button-type': 'off'
|
||||
'@eslint-react/dom/no-missing-button-type': 'off',
|
||||
'@eslint-react/hooks-extra/prefer-use-state-lazy-initialization': 'off'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
45
package.json
45
package.json
|
@ -15,7 +15,7 @@
|
|||
"pack:firefox": "wxt zip -b firefox",
|
||||
"lint": "eslint --fix --flag unstable_ts_config",
|
||||
"clear": "rimraf .output",
|
||||
"tsc": "tsc --noEmit",
|
||||
"check": "tsc --noEmit",
|
||||
"prepare": "husky",
|
||||
"postinstall": "wxt prepare"
|
||||
},
|
||||
|
@ -45,7 +45,7 @@
|
|||
"homepage": "https://github.com/molvqingtai/WebChat",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@lottiefiles/dotlottie-react": "^0.9.1",
|
||||
"@lottiefiles/dotlottie-react": "^0.9.2",
|
||||
"@perfsee/jsonr": "^1.13.0",
|
||||
"@radix-ui/react-avatar": "^1.1.1",
|
||||
"@radix-ui/react-checkbox": "^1.1.2",
|
||||
|
@ -54,6 +54,8 @@
|
|||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@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-scroll-area": "^1.2.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
|
@ -62,23 +64,22 @@
|
|||
"@resreq/timer": "^1.1.6",
|
||||
"@rtco/client": "^0.2.17",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@webext-core/messaging": "^1.4.0",
|
||||
"@webext-core/messaging": "^2.0.2",
|
||||
"@webext-core/proxy-service": "^1.2.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"danmu": "^0.12.0",
|
||||
"danmu": "^0.14.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^11.11.8",
|
||||
"framer-motion": "^11.11.10",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"lucide-react": "^0.452.0",
|
||||
"lucide-react": "^0.453.0",
|
||||
"nanoid": "^5.0.7",
|
||||
"next-themes": "^0.3.0",
|
||||
"react": "^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-use": "^17.5.1",
|
||||
"react-virtuoso": "^4.10.4",
|
||||
"react-virtuoso": "^4.12.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remesh": "^4.2.2",
|
||||
|
@ -86,7 +87,7 @@
|
|||
"remesh-react": "^4.1.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"sonner": "^1.5.0",
|
||||
"tailwind-merge": "^2.5.3",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"trystero": "^0.20.0",
|
||||
"type-fest": "^4.26.1",
|
||||
"unstorage": "1.12.0",
|
||||
|
@ -95,22 +96,22 @@
|
|||
"devDependencies": {
|
||||
"@commitlint/cli": "^19.5.0",
|
||||
"@commitlint/config-conventional": "^19.5.0",
|
||||
"@eslint-react/eslint-plugin": "^1.15.0",
|
||||
"@eslint/js": "^9.12.0",
|
||||
"@eslint-react/eslint-plugin": "^1.15.1",
|
||||
"@eslint/js": "^9.13.0",
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
"@semantic-release/exec": "^6.0.3",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
"@types/node": "^22.7.5",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
||||
"@types/node": "^22.8.1",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@typescript-eslint/parser": "^8.8.1",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"@typescript-eslint/parser": "^8.11.0",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9.12.0",
|
||||
"eslint": "^9.13.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-tailwindcss": "^3.17.5",
|
||||
|
@ -123,14 +124,14 @@
|
|||
"postcss-rem-to-responsive-pixel": "^6.0.2",
|
||||
"prettier": "^3.3.3",
|
||||
"rimraf": "^6.0.1",
|
||||
"semantic-release": "^24.1.2",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"semantic-release": "^24.2.0",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript-eslint": "^8.8.1",
|
||||
"typescript-eslint": "^8.11.0",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"webext-bridge": "^6.0.1",
|
||||
"wxt": "^0.19.11"
|
||||
"wxt": "^0.19.13"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": "eslint --fix --flag unstable_ts_config"
|
||||
|
|
16876
pnpm-lock.yaml
16876
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 Main from '@/app/content/views/Main'
|
||||
import AppButton from '@/app/content/views/AppButton'
|
||||
import AppContainer from '@/app/content/views/AppContainer'
|
||||
import AppMain from '@/app/content/views/AppMain'
|
||||
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
||||
import RoomDomain from '@/domain/Room'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
|
@ -10,9 +10,12 @@ import Setup from '@/app/content/views/Setup'
|
|||
import MessageListDomain from '@/domain/MessageList'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Toaster } from 'sonner'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
|
||||
import DanmakuContainer from './components/DanmakuContainer'
|
||||
import DanmakuDomain from '@/domain/Danmaku'
|
||||
import AppStatusDomain from '@/domain/AppStatus'
|
||||
import { cn } from '@/utils'
|
||||
|
||||
/**
|
||||
* Fix requestAnimationFrame error in jest
|
||||
|
@ -31,9 +34,12 @@ export default function App() {
|
|||
const danmakuDomain = useRemeshDomain(DanmakuDomain())
|
||||
const danmakuIsEnabled = useRemeshQuery(danmakuDomain.query.IsEnabledQuery())
|
||||
const userInfoSetFinished = useRemeshQuery(userInfoDomain.query.UserInfoSetIsFinishedQuery())
|
||||
const userInfoLoadFinished = useRemeshQuery(userInfoDomain.query.UserInfoLoadIsFinishedQuery())
|
||||
const messageListLoadFinished = useRemeshQuery(messageListDomain.query.LoadIsFinishedQuery())
|
||||
const userInfoLoadFinished = useRemeshQuery(userInfoDomain.query.UserInfoLoadIsFinishedQuery())
|
||||
const appStatusDomain = useRemeshDomain(AppStatusDomain())
|
||||
const appStatusLoadIsFinished = useRemeshQuery(appStatusDomain.query.StatusLoadIsFinishedQuery())
|
||||
|
||||
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
|
||||
const notUserInfo = userInfoLoadFinished && !userInfoSetFinished
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -57,16 +63,30 @@ export default function App() {
|
|||
}, [danmakuIsEnabled])
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppContainer>
|
||||
appStatusLoadIsFinished && (
|
||||
<div id="app" className={cn('contents', userInfo?.themeMode)}>
|
||||
<AppMain>
|
||||
<Header />
|
||||
<Main />
|
||||
<Footer />
|
||||
{notUserInfo && <Setup />}
|
||||
<AnimatePresence>
|
||||
{notUserInfo && (
|
||||
<motion.div
|
||||
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>
|
||||
</AppContainer>
|
||||
</AppMain>
|
||||
<AppButton></AppButton>
|
||||
|
||||
<DanmakuContainer ref={danmakuContainerRef} />
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -20,7 +20,7 @@ const DanmakuMessage: FC<PromptItemProps> = ({ data, className, onClick, onMouse
|
|||
onMouseLeave={onMouseLeave}
|
||||
onClick={onClick}
|
||||
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
|
||||
)}
|
||||
>
|
||||
|
|
|
@ -10,7 +10,7 @@ export interface EmojiButtonProps {
|
|||
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/issues/1666
|
||||
|
@ -30,20 +30,23 @@ const EmojiButton: FC<EmojiButtonProps> = ({ onSelect }) => {
|
|||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Button variant="ghost" size="icon" className="dark:text-white">
|
||||
<SmileIcon size={20} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="z-infinity w-72 rounded-xl px-0" onCloseAutoFocus={handleCloseAutoFocus}>
|
||||
<ScrollArea className="size-72 px-3">
|
||||
<PopoverContent
|
||||
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) => {
|
||||
return (
|
||||
<div key={index} className="grid grid-cols-8">
|
||||
<div key={index} className="grid grid-cols-6">
|
||||
{group.map((emoji, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
size="sm"
|
||||
className="text-base"
|
||||
size="icon"
|
||||
className="text-xl"
|
||||
variant="ghost"
|
||||
onClick={() => handleSelect(emoji)}
|
||||
>
|
||||
|
|
|
@ -33,8 +33,8 @@ const LikeButton: FC<LikeButtonProps> & { Icon: FC<LikeButtonIconProps> } = ({
|
|||
onClick={handleClick}
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'grid items-center overflow-hidden rounded-full leading-none transition-all select-none',
|
||||
checked ? 'text-orange-500' : 'text-slate-500',
|
||||
'grid items-center overflow-hidden rounded-full leading-none transition-all select-none dark:bg-slate-600',
|
||||
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'
|
||||
)}
|
||||
size="xs"
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
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 { Textarea } from '@/components/ui/Textarea'
|
||||
import { ScrollArea } from '@/components/ui/ScrollArea'
|
||||
|
||||
export interface MessageInputProps {
|
||||
|
@ -13,11 +12,16 @@ export interface MessageInputProps {
|
|||
autoFocus?: boolean
|
||||
disabled?: boolean
|
||||
onInput?: (e: ChangeEvent<HTMLTextAreaElement>) => void
|
||||
onEnter?: (e: KeyboardEvent<HTMLTextAreaElement>) => void
|
||||
onKeyDown?: (e: KeyboardEvent<HTMLTextAreaElement>) => void
|
||||
onCompositionStart?: (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>(
|
||||
(
|
||||
{
|
||||
|
@ -25,36 +29,26 @@ const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
|||
className,
|
||||
maxLength = 500,
|
||||
onInput,
|
||||
onEnter,
|
||||
onKeyDown,
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
preview,
|
||||
autoFocus,
|
||||
disabled
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !(e.shiftKey || e.ctrlKey || e.altKey || e.metaKey)) {
|
||||
e.preventDefault()
|
||||
onEnter?.(e)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
{preview ? (
|
||||
<Markdown className="max-h-28 rounded-lg border border-input bg-gray-50 2xl:max-h-40">{value}</Markdown>
|
||||
) : (
|
||||
<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">
|
||||
<Textarea
|
||||
ref={ref}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyDown={onKeyDown}
|
||||
autoFocus={autoFocus}
|
||||
maxLength={maxLength}
|
||||
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"
|
||||
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"
|
||||
rows={2}
|
||||
value={value}
|
||||
spellCheck={false}
|
||||
onCompositionStart={onCompositionStart}
|
||||
onCompositionEnd={onCompositionEnd}
|
||||
placeholder="Type your message here."
|
||||
|
@ -62,8 +56,7 @@ const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
|||
disabled={disabled}
|
||||
/>
|
||||
</ScrollArea>
|
||||
)}
|
||||
<div className="absolute bottom-1 right-3 rounded-lg text-xs text-slate-400">
|
||||
<div className="absolute bottom-1 right-3 rounded-lg text-xs text-slate-400 dark:text-slate-50">
|
||||
{value?.length ?? 0}/{maxLength}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { type FC } from 'react'
|
||||
import { FrownIcon, ThumbsUpIcon } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import LikeButton from './LikeButton'
|
||||
import FormatDate from './FormatDate'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/Avatar'
|
||||
|
@ -26,10 +25,31 @@ const MessageItem: FC<MessageItemProps> = (props) => {
|
|||
const handleHateChange = (checked: boolean) => {
|
||||
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 (
|
||||
<div
|
||||
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>
|
||||
<AvatarImage src={props.data.userAvatar} className="size-full" alt="avatar" />
|
||||
|
@ -37,14 +57,14 @@ const MessageItem: FC<MessageItemProps> = (props) => {
|
|||
</Avatar>
|
||||
<div className="overflow-hidden">
|
||||
<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>
|
||||
<FormatDate className="text-xs text-slate-400" date={props.data.date}></FormatDate>
|
||||
<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 dark:text-slate-100" date={props.data.date}></FormatDate>
|
||||
</div>
|
||||
<div>
|
||||
<div className="pb-2">
|
||||
<Markdown>{props.data.body}</Markdown>
|
||||
<Markdown>{content}</Markdown>
|
||||
</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
|
||||
checked={props.like}
|
||||
onChange={(checked) => handleLikeChange(checked)}
|
||||
|
|
|
@ -12,8 +12,9 @@ const MessageList: FC<MessageListProps> = ({ children }) => {
|
|||
const [scrollParentRef, setScrollParentRef] = useState<HTMLDivElement | null>(null)
|
||||
|
||||
return (
|
||||
<ScrollArea ref={setScrollParentRef}>
|
||||
<ScrollArea ref={setScrollParentRef} className="dark:bg-slate-900">
|
||||
<Virtuoso
|
||||
defaultItemHeight={108}
|
||||
followOutput={(isAtBottom: boolean) => (isAtBottom ? 'smooth' : 'auto')}
|
||||
initialTopMostItemIndex={{ index: 'LAST', align: 'end' }}
|
||||
data={children}
|
||||
|
|
|
@ -12,8 +12,8 @@ export interface PromptItemProps {
|
|||
|
||||
const PromptItem: FC<PromptItemProps> = ({ data, className }) => {
|
||||
return (
|
||||
<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">
|
||||
<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 dark:bg-slate-800">
|
||||
<Avatar className="size-4">
|
||||
<AvatarImage src={data.userAvatar} className="size-full" alt="avatar" />
|
||||
<AvatarFallback>{data.username.at(0)}</AvatarFallback>
|
||||
|
|
|
@ -2,7 +2,7 @@ import React from 'react'
|
|||
import { createRoot } from 'react-dom/client'
|
||||
import { Remesh } from 'remesh'
|
||||
import { RemeshRoot, RemeshScope } from 'remesh-react'
|
||||
import { RemeshLogger } from 'remesh-logger'
|
||||
// import { RemeshLogger } from 'remesh-logger'
|
||||
import { defineContentScript } from 'wxt/sandbox'
|
||||
import { createShadowRootUi } from 'wxt/client'
|
||||
|
||||
|
@ -15,8 +15,8 @@ import { ToastImpl } from '@/domain/impls/Toast'
|
|||
import { PeerRoomImpl } from '@/domain/impls/PeerRoom2'
|
||||
import '@/assets/styles/tailwind.css'
|
||||
import '@/assets/styles/sonner.css'
|
||||
import { createElement } from '@/utils'
|
||||
import NotificationDomain from '@/domain/Notification'
|
||||
import { createElement } from '@/utils'
|
||||
|
||||
export default defineContentScript({
|
||||
cssInjectionMode: 'ui',
|
||||
|
@ -24,6 +24,13 @@ export default defineContentScript({
|
|||
matches: ['https://*/*'],
|
||||
excludeMatches: ['*://localhost/*', '*://127.0.0.1/*', '*://*.csdn.net/*', '*://*.csdn.com/*'],
|
||||
async main(ctx) {
|
||||
window.CSS.registerProperty({
|
||||
name: '--shimmer-angle',
|
||||
syntax: '<angle>',
|
||||
inherits: false,
|
||||
initialValue: '0deg'
|
||||
})
|
||||
|
||||
const store = Remesh.store({
|
||||
externs: [
|
||||
LocalStorageImpl,
|
||||
|
@ -45,9 +52,8 @@ export default defineContentScript({
|
|||
mode: 'open',
|
||||
isolateEvents: ['keyup', 'keydown', 'keypress'],
|
||||
onMount: (container) => {
|
||||
const app = createElement('<div id="app"></div>')
|
||||
const app = createElement('<div id="root"></div>')
|
||||
container.append(app)
|
||||
|
||||
const root = createRoot(app)
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
|
|
|
@ -1,15 +1,13 @@
|
|||
import { type FC, useState, type MouseEvent, useRef } from 'react'
|
||||
import { SettingsIcon, MoonIcon, SunIcon } from 'lucide-react'
|
||||
import { type FC, useState, type MouseEvent, useEffect } from 'react'
|
||||
import { SettingsIcon, MoonIcon, SunIcon, HandIcon } from 'lucide-react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
import { browser } from 'wxt/browser'
|
||||
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { EVENT } from '@/constants/event'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
import useClickAway from '@/hooks/useClickAway'
|
||||
import useTriggerAway from '@/hooks/useTriggerAway'
|
||||
import { checkSystemDarkMode, cn } from '@/utils'
|
||||
import ToastDomain from '@/domain/Toast'
|
||||
import LogoIcon0 from '@/assets/images/logo-0.svg'
|
||||
import LogoIcon1 from '@/assets/images/logo-1.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 { getDay } from 'date-fns'
|
||||
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 appStatusDomain = useRemeshDomain(AppStatusDomain())
|
||||
const appOpenStatus = useRemeshQuery(appStatusDomain.query.OpenQuery())
|
||||
const hasUnreadQuery = useRemeshQuery(appStatusDomain.query.HasUnreadQuery())
|
||||
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||
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 isDarkMode =
|
||||
|
@ -36,11 +41,28 @@ const AppButton: FC = () => {
|
|||
|
||||
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, () => {
|
||||
setMenuOpen(false)
|
||||
}, ['click'])
|
||||
useWindowResize(({ width, height }) => {
|
||||
send(appStatusDomain.command.UpdatePositionCommand({ x: width - 50, y: height - 22 }))
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
send(appStatusDomain.command.UpdatePositionCommand({ x, y }))
|
||||
}, [x, y])
|
||||
|
||||
const { setRef: appMenuRef } = useTriggerAway(['click'], () => setMenuOpen(false))
|
||||
|
||||
const handleToggleMenu = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
|
@ -49,7 +71,6 @@ const AppButton: FC = () => {
|
|||
|
||||
const handleSwitchTheme = () => {
|
||||
if (userInfo) {
|
||||
send(toastDomain.command.WarningCommand('Developer is too lazy~'))
|
||||
send(userInfoDomain.command.UpdateUserInfoCommand({ ...userInfo, themeMode: isDarkMode ? 'light' : 'dark' }))
|
||||
} else {
|
||||
handleOpenOptionsPage()
|
||||
|
@ -65,7 +86,15 @@ const AppButton: FC = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="fixed bottom-5 right-5 z-infinity grid select-none justify-center gap-y-3">
|
||||
<div
|
||||
ref={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>
|
||||
{menuOpen && (
|
||||
<motion.div
|
||||
|
@ -84,7 +113,7 @@ const AppButton: FC = () => {
|
|||
className={cn(
|
||||
'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 ? 'bg-slate-800 text-white' : 'bg-white text-orange-400'
|
||||
isDarkMode ? 'bg-slate-950 text-white' : 'bg-white text-orange-400'
|
||||
)}
|
||||
>
|
||||
<MoonIcon size={20} />
|
||||
|
@ -92,20 +121,19 @@ const AppButton: FC = () => {
|
|||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleOpenOptionsPage}
|
||||
variant="outline"
|
||||
className="pointer-events-auto size-10 rounded-full p-0 shadow"
|
||||
>
|
||||
<Button onClick={handleOpenOptionsPage} variant="outline" className="size-10 rounded-full p-0 shadow">
|
||||
<SettingsIcon size={20} />
|
||||
</Button>
|
||||
<Button ref={appButtonRef} variant="outline" className="size-10 cursor-grab rounded-full p-0 shadow">
|
||||
<HandIcon size={20} />
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<Button
|
||||
onClick={handleToggleApp}
|
||||
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>
|
||||
{hasUnreadQuery && (
|
||||
|
@ -114,7 +142,7 @@ const AppButton: FC = () => {
|
|||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
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
|
||||
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>
|
||||
)}
|
||||
</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>
|
||||
</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 { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
||||
import MessageInput from '../../components/MessageInput'
|
||||
|
@ -7,48 +7,289 @@ import { Button } from '@/components/ui/Button'
|
|||
import MessageInputDomain from '@/domain/MessageInput'
|
||||
import { MESSAGE_MAX_LENGTH } from '@/constants/config'
|
||||
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 send = useRemeshSend()
|
||||
const toastDomain = useRemeshDomain(ToastDomain())
|
||||
const roomDomain = useRemeshDomain(RoomDomain())
|
||||
const messageInputDomain = useRemeshDomain(MessageInputDomain())
|
||||
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 isComposing = useRef(false)
|
||||
const { x, y, selectionStart, selectionEnd, setRef } = useCursorPosition()
|
||||
|
||||
const handleInput = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
send(messageInputDomain.command.InputCommand(e.target.value))
|
||||
const [autoCompleteListShow, setAutoCompleteListShow] = useState(false)
|
||||
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 = () => {
|
||||
if (isComposing.current) return
|
||||
if (!message.trim()) return
|
||||
send(roomDomain.command.SendTextMessageCommand(message.trim()))
|
||||
if (!`${message}`.trim()) {
|
||||
return send(toastDomain.command.WarningCommand('Message cannot be empty.'))
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
const handleEmojiSelect = (emoji: string) => {
|
||||
send(messageInputDomain.command.InputCommand(`${message}${emoji}`))
|
||||
inputRef.current?.focus()
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (autoCompleteListShow && autoCompleteList.length) {
|
||||
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 (
|
||||
<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
|
||||
ref={inputRef}
|
||||
ref={shareRef}
|
||||
value={message}
|
||||
onEnter={handleSend}
|
||||
onInput={handleInput}
|
||||
onCompositionEnd={() => (isComposing.current = false)}
|
||||
onCompositionStart={() => (isComposing.current = true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
maxLength={MESSAGE_MAX_LENGTH}
|
||||
></MessageInput>
|
||||
<div className="flex items-center">
|
||||
<EmojiButton onSelect={handleEmojiSelect}></EmojiButton>
|
||||
{/* <Button variant="ghost" size="icon">
|
||||
<ImageIcon size={20} />
|
||||
</Button> */}
|
||||
<EmojiButton onSelect={handleInjectEmoji}></EmojiButton>
|
||||
<Button className="ml-auto" size="sm" onClick={handleSend}>
|
||||
<span className="mr-2">Send</span>
|
||||
<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 { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar'
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/HoverCard'
|
||||
|
@ -7,6 +7,7 @@ import { cn, getSiteInfo } from '@/utils'
|
|||
import { useRemeshDomain, useRemeshQuery } from 'remesh-react'
|
||||
import RoomDomain from '@/domain/Room'
|
||||
import { ScrollArea } from '@/components/ui/ScrollArea'
|
||||
import { Virtuoso } from 'react-virtuoso'
|
||||
|
||||
const Header: FC = () => {
|
||||
const siteInfo = getSiteInfo()
|
||||
|
@ -14,8 +15,10 @@ const Header: FC = () => {
|
|||
const userList = useRemeshQuery(roomDomain.query.UserListQuery())
|
||||
const onlineCount = userList.length
|
||||
|
||||
const [scrollParentRef, setScrollParentRef] = useState<HTMLDivElement | null>(null)
|
||||
|
||||
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">
|
||||
<AvatarImage src={siteInfo.icon} alt="favicon" />
|
||||
<AvatarFallback>
|
||||
|
@ -25,7 +28,7 @@ const Header: FC = () => {
|
|||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<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, '')}
|
||||
</span>
|
||||
</Button>
|
||||
|
@ -41,7 +44,9 @@ const Header: FC = () => {
|
|||
<div className="grid items-center">
|
||||
<h4 className="truncate text-sm font-semibold">{siteInfo.title}</h4>
|
||||
{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>
|
||||
|
@ -65,21 +70,26 @@ const Header: FC = () => {
|
|||
)}
|
||||
></span>
|
||||
</span>
|
||||
<span>ONLINE {onlineCount > 99 ? '99+' : onlineCount}</span>
|
||||
<span className="dark:text-slate-50">ONLINE {onlineCount > 99 ? '99+' : onlineCount}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-44 rounded-lg px-0 py-2">
|
||||
<ScrollArea className="max-h-80">
|
||||
{userList.map((user) => (
|
||||
<div className="flex items-center gap-x-2 px-4 py-2 [content-visibility:auto]" key={user.userId}>
|
||||
<Avatar className="size-6 shrink-0">
|
||||
<AvatarImage src={user.userAvatar} alt="avatar" />
|
||||
<HoverCardContent className="w-36 rounded-lg p-0">
|
||||
<ScrollArea className="max-h-[204px] min-h-9 p-1" ref={setScrollParentRef}>
|
||||
<Virtuoso
|
||||
data={userList}
|
||||
defaultItemHeight={28}
|
||||
customScrollParent={scrollParentRef!}
|
||||
itemContent={(index, user) => (
|
||||
<div className={cn('flex items-center gap-x-2 rounded-md px-2 py-1.5 outline-none')}>
|
||||
<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-sm text-slate-500">{user.username}</div>
|
||||
<div className="flex-1 truncate text-xs text-slate-500 dark:text-slate-50">{user.username}</div>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
></Virtuoso>
|
||||
</ScrollArea>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
|
|
@ -7,7 +7,6 @@ import PromptItem from '../../components/PromptItem'
|
|||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
import RoomDomain, { MessageType } from '@/domain/Room'
|
||||
import MessageListDomain from '@/domain/MessageList'
|
||||
import BlurFade from '@/components/magicui/BlurFade'
|
||||
|
||||
const Main: FC = () => {
|
||||
const send = useRemeshSend()
|
||||
|
@ -39,7 +38,6 @@ const Main: FC = () => {
|
|||
<MessageList>
|
||||
{messageList.map((message, index) =>
|
||||
message.type === MessageType.Normal ? (
|
||||
<BlurFade key={message.id} duration={0.1} yOffset={0}>
|
||||
<MessageItem
|
||||
key={message.id}
|
||||
data={message}
|
||||
|
@ -47,16 +45,14 @@ const Main: FC = () => {
|
|||
hate={message.hate}
|
||||
onLikeChange={() => handleLikeChange(message.id)}
|
||||
onHateChange={() => handleHateChange(message.id)}
|
||||
className="duration-300 animate-in fade-in-0"
|
||||
></MessageItem>
|
||||
</BlurFade>
|
||||
) : (
|
||||
<BlurFade key={message.id} duration={0.1} yOffset={0}>
|
||||
<PromptItem
|
||||
key={message.id}
|
||||
data={message}
|
||||
className={`${index === 0 ? 'pt-4' : ''} ${index === messageList.length - 1 ? 'pb-4' : ''}`}
|
||||
></PromptItem>
|
||||
</BlurFade>
|
||||
)
|
||||
)}
|
||||
</MessageList>
|
||||
|
|
|
@ -43,7 +43,8 @@ const generateUserInfo = async (): Promise<UserInfo> => {
|
|||
createTime: Date.now(),
|
||||
themeMode: checkSystemDarkMode() ? 'dark' : 'system',
|
||||
danmakuEnabled: true,
|
||||
notificationEnabled: false
|
||||
notificationEnabled: true,
|
||||
notificationType: 'all'
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -58,7 +59,8 @@ const generateMessage = async (userInfo: UserInfo): Promise<Message> => {
|
|||
username,
|
||||
userAvatar,
|
||||
likeUsers: mockTextList.length ? [] : [{ userId, username, userAvatar }],
|
||||
hateUsers: []
|
||||
hateUsers: [],
|
||||
atUsers: []
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -68,6 +70,7 @@ const Setup: FC = () => {
|
|||
const messageListDomain = useRemeshDomain(MessageListDomain())
|
||||
|
||||
const [userInfo, setUserInfo] = useState<UserInfo>()
|
||||
|
||||
const handleSetup = () => {
|
||||
send(userInfoDomain.command.UpdateUserInfoCommand(userInfo!))
|
||||
send(messageListDomain.command.ClearListCommand())
|
||||
|
|
|
@ -4,17 +4,23 @@ import ProfileForm from './components/ProfileForm'
|
|||
import BadgeList from './components/BadgeList'
|
||||
import Layout from './components/Layout'
|
||||
import VersionLink from './components/VersionLink'
|
||||
import { useRemeshDomain, useRemeshQuery } from 'remesh-react'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
|
||||
function App() {
|
||||
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
|
||||
return (
|
||||
<div className={userInfo?.themeMode}>
|
||||
<Layout>
|
||||
<VersionLink></VersionLink>
|
||||
<Main>
|
||||
<ProfileForm></ProfileForm>
|
||||
<Toaster richColors position="top-center" />
|
||||
<Toaster richColors position="top-center" duration={1000000} />
|
||||
</Main>
|
||||
<BadgeList></BadgeList>
|
||||
</Layout>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ export interface LayoutProps {
|
|||
|
||||
const Layout: FC<LayoutProps> = ({ children }) => {
|
||||
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">
|
||||
<Meteors number={30} />
|
||||
</div>
|
||||
|
|
|
@ -7,7 +7,7 @@ export interface MainProps {
|
|||
const Main: FC<MainProps> = ({ children }) => {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -3,20 +3,20 @@ import { useForm } from 'react-hook-form'
|
|||
import { valibotResolver } from '@hookform/resolvers/valibot'
|
||||
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { useEffect } from 'react'
|
||||
import { ReactNode, useEffect, type FC } from 'react'
|
||||
import AvatarSelect from './AvatarSelect'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/Form'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
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 { Label } from '@/components/ui/Label'
|
||||
import { RefreshCcwIcon } from 'lucide-react'
|
||||
import { MAX_AVATAR_SIZE } from '@/constants/config'
|
||||
import { ToastImpl } from '@/domain/impls/Toast'
|
||||
import BlurFade from '@/components/magicui/BlurFade'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
import Link from '@/components/Link'
|
||||
|
||||
const defaultUserInfo: UserInfo = {
|
||||
|
@ -26,7 +26,8 @@ const defaultUserInfo: UserInfo = {
|
|||
createTime: Date.now(),
|
||||
themeMode: checkSystemDarkMode() ? 'dark' : 'system',
|
||||
danmakuEnabled: true,
|
||||
notificationEnabled: false
|
||||
notificationEnabled: true,
|
||||
notificationType: 'all'
|
||||
}
|
||||
|
||||
const formSchema = v.object({
|
||||
|
@ -34,13 +35,9 @@ const formSchema = v.object({
|
|||
createTime: v.number(),
|
||||
// Pure numeric strings will be converted to number
|
||||
// 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(
|
||||
v.string(),
|
||||
v.trim(),
|
||||
v.minBytes(1, 'Please enter your username.'),
|
||||
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.')
|
||||
),
|
||||
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 = () => {
|
||||
const ProfileForm: FC = () => {
|
||||
const send = useRemeshSend()
|
||||
const toast = ToastImpl.value
|
||||
|
||||
|
@ -136,7 +133,7 @@ const ProfileForm = () => {
|
|||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormLabel className="font-semibold">Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Please enter your username" {...field} />
|
||||
</FormControl>
|
||||
|
@ -150,7 +147,6 @@ const ProfileForm = () => {
|
|||
name="danmakuEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
{/* <FormLabel>Username</FormLabel> */}
|
||||
<FormControl>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
|
@ -159,7 +155,7 @@ const ProfileForm = () => {
|
|||
onCheckedChange={field.onChange}
|
||||
checked={field.value}
|
||||
/>
|
||||
<FormLabel className="cursor-pointer" htmlFor="enable-danmaku">
|
||||
<FormLabel className="cursor-pointer font-semibold" htmlFor="enable-danmaku">
|
||||
Enable Danmaku
|
||||
</FormLabel>
|
||||
</div>
|
||||
|
@ -174,25 +170,67 @@ const ProfileForm = () => {
|
|||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notificationType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notificationEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
{/* <FormLabel>Username</FormLabel> */}
|
||||
<FormControl>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
defaultChecked={false}
|
||||
id="notification-enabled"
|
||||
id="enable-notification"
|
||||
onCheckedChange={field.onChange}
|
||||
checked={field.value}
|
||||
/>
|
||||
<FormLabel className="cursor-pointer" htmlFor="notification-enabled">
|
||||
<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>
|
||||
<FormDescription>Enabling this option will display desktop notifications for messages.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -203,20 +241,26 @@ const ProfileForm = () => {
|
|||
name="themeMode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Theme Mode</FormLabel>
|
||||
<FormLabel className="font-semibold">Theme Mode</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup className="flex gap-x-4" onValueChange={field.onChange} value={field.value}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="system" id="r1" />
|
||||
<Label htmlFor="r1">System</Label>
|
||||
<RadioGroupItem value="system" id="system" />
|
||||
<Label className="cursor-pointer" htmlFor="system">
|
||||
System
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="light" id="r2" />
|
||||
<Label htmlFor="r2">Light</Label>
|
||||
<RadioGroupItem value="light" id="light" />
|
||||
<Label className="cursor-pointer" htmlFor="light">
|
||||
Light
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="dark" id="r3" />
|
||||
<Label htmlFor="r3">Dark</Label>
|
||||
<RadioGroupItem value="dark" id="dark" />
|
||||
<Label className="cursor-pointer" htmlFor="dark">
|
||||
Dark
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
|
|
@ -673,12 +673,15 @@ section:has([data-sonner-toaster]) {
|
|||
|
||||
/* Custom styles */
|
||||
:where([data-sonner-toaster]) {
|
||||
width: 200px;
|
||||
max-width: 300px;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast][data-styled='true']) {
|
||||
width: 200px;
|
||||
max-width: 300px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 9999px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
|
|
@ -81,11 +81,10 @@
|
|||
all: initial !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} />,
|
||||
table: ({ className, ...props }) => (
|
||||
<div className="my-2 w-full">
|
||||
<ScrollArea>
|
||||
<ScrollArea scrollLock={false}>
|
||||
<table className={cn('my-0 w-full rounded-md', className)} {...props} />
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
|
@ -106,14 +106,14 @@ const Markdown: FC<MarkdownProps> = ({ children = '', className }) => {
|
|||
*
|
||||
*/
|
||||
code: ({ className, ...props }) => (
|
||||
<ScrollArea>
|
||||
<ScrollArea className="overscroll-y-auto" scrollLock={false}>
|
||||
<code className={cn('text-sm', className)} {...props}></code>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
)
|
||||
}}
|
||||
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}
|
||||
</ReactMarkdown>
|
||||
|
|
|
@ -29,7 +29,7 @@ const AvatarFallback = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
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}
|
||||
/>
|
||||
))
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from 'react'
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||
import { cn } from '@/utils/index'
|
||||
import { cn, getRootNode } from '@/utils'
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
|
@ -10,9 +10,9 @@ const PopoverContent = React.forwardRef<
|
|||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => {
|
||||
const shadowRoot = document.querySelector(__NAME__)!.shadowRoot! as unknown as HTMLElement
|
||||
const root = getRootNode()
|
||||
return (
|
||||
<PopoverPrimitive.Portal container={shadowRoot}>
|
||||
<PopoverPrimitive.Portal container={root}>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
|
|
|
@ -5,10 +5,13 @@ import { cn } from '@/utils/index'
|
|||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & { scrollLock?: boolean }
|
||||
>(({ className, children, scrollLock = true, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root className={cn('relative grid grid-rows-[1fr] overflow-hidden', className)} {...props}>
|
||||
<ScrollAreaPrimitive.Viewport ref={ref} className="size-full overscroll-none rounded-[inherit]">
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
ref={ref}
|
||||
className={cn('size-full rounded-[inherit]', scrollLock ? 'overscroll-none' : 'overscroll-auto')}
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
|
|
|
@ -8,7 +8,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ classNa
|
|||
return (
|
||||
<textarea
|
||||
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
|
||||
)}
|
||||
ref={ref}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "@radix-ui/react-icons"
|
||||
import * as React from 'react'
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
|
||||
import { CheckIcon } from '@radix-ui/react-icons'
|
||||
|
||||
import { cn } from "@/utils/index"
|
||||
import { cn } from '@/utils/index'
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
|
@ -11,14 +11,12 @@ const Checkbox = React.forwardRef<
|
|||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
|
|
|
@ -9,11 +9,13 @@ import { map } from 'rxjs'
|
|||
export interface AppStatus {
|
||||
open: boolean
|
||||
unread: number
|
||||
position: { x: number; y: number }
|
||||
}
|
||||
|
||||
export const defaultStatusState = {
|
||||
open: false,
|
||||
unread: 0
|
||||
unread: 0,
|
||||
position: { x: window.innerWidth - 50, y: window.innerHeight - 22 }
|
||||
}
|
||||
|
||||
const AppStatusDomain = Remesh.domain({
|
||||
|
@ -32,8 +34,8 @@ const AppStatusDomain = Remesh.domain({
|
|||
|
||||
const StatusLoadIsFinishedQuery = domain.query({
|
||||
name: 'AppStatus.StatusLoadIsFinishedQuery',
|
||||
impl: () => {
|
||||
return StatusLoadModule.query.IsFinishedQuery()
|
||||
impl: ({ get }) => {
|
||||
return get(StatusLoadModule.query.IsFinishedQuery())
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -56,6 +58,13 @@ const AppStatusDomain = Remesh.domain({
|
|||
}
|
||||
})
|
||||
|
||||
const PositionQuery = domain.query({
|
||||
name: 'AppStatus.PositionQuery',
|
||||
impl: ({ get }) => {
|
||||
return get(StatusState()).position
|
||||
}
|
||||
})
|
||||
|
||||
const HasUnreadQuery = domain.query({
|
||||
name: 'AppStatus.HasUnreadQuery',
|
||||
impl: ({ get }) => {
|
||||
|
@ -68,6 +77,7 @@ const AppStatusDomain = Remesh.domain({
|
|||
impl: ({ get }, value: boolean) => {
|
||||
const status = get(StatusState())
|
||||
return UpdateStatusCommand({
|
||||
...status,
|
||||
unread: value ? 0 : status.unread,
|
||||
open: value
|
||||
})
|
||||
|
@ -85,6 +95,17 @@ const AppStatusDomain = Remesh.domain({
|
|||
}
|
||||
})
|
||||
|
||||
const UpdatePositionCommand = domain.command({
|
||||
name: 'AppStatus.UpdatePositionCommand',
|
||||
impl: ({ get }, value: { x: number; y: number }) => {
|
||||
const status = get(StatusState())
|
||||
return UpdateStatusCommand({
|
||||
...status,
|
||||
position: value
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const UpdateStatusCommand = domain.command({
|
||||
name: 'AppStatus.UpdateStatusCommand',
|
||||
impl: (_, value: AppStatus) => {
|
||||
|
@ -128,11 +149,13 @@ const AppStatusDomain = Remesh.domain({
|
|||
OpenQuery,
|
||||
UnreadQuery,
|
||||
HasUnreadQuery,
|
||||
PositionQuery,
|
||||
StatusLoadIsFinishedQuery
|
||||
},
|
||||
command: {
|
||||
UpdateOpenCommand,
|
||||
UpdateUnreadCommand
|
||||
UpdateUnreadCommand,
|
||||
UpdatePositionCommand
|
||||
},
|
||||
event: {
|
||||
SyncToStorageEvent
|
||||
|
|
|
@ -2,7 +2,7 @@ import { Remesh } from 'remesh'
|
|||
import { DanmakuExtern } from './externs/Danmaku'
|
||||
import RoomDomain, { TextMessage } from './Room'
|
||||
import UserInfoDomain from './UserInfo'
|
||||
import { map, merge, of } from 'rxjs'
|
||||
import { map, merge } from 'rxjs'
|
||||
|
||||
const DanmakuDomain = Remesh.domain({
|
||||
name: 'DanmakuDomain',
|
||||
|
|
|
@ -16,6 +16,10 @@ export interface MessageUser {
|
|||
userAvatar: string
|
||||
}
|
||||
|
||||
export interface AtUser extends MessageUser {
|
||||
positions: [number, number][]
|
||||
}
|
||||
|
||||
export interface NormalMessage extends MessageUser {
|
||||
type: MessageType.Normal
|
||||
id: string
|
||||
|
@ -23,6 +27,7 @@ export interface NormalMessage extends MessageUser {
|
|||
date: number
|
||||
likeUsers: MessageUser[]
|
||||
hateUsers: MessageUser[]
|
||||
atUsers: AtUser[]
|
||||
}
|
||||
|
||||
export interface PromptMessage extends MessageUser {
|
||||
|
|
|
@ -69,11 +69,27 @@ const NotificationDomain = Remesh.domain({
|
|||
name: 'Notification.OnRoomMessageEffect',
|
||||
impl: ({ fromEvent, get }) => {
|
||||
const onTextMessage$ = fromEvent(roomDomain.event.OnTextMessageEvent)
|
||||
|
||||
const onMessage$ = merge(onTextMessage$).pipe(
|
||||
map((message) => {
|
||||
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 { 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 MessageListDomain, { MessageType } from '@/domain/MessageList'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
|
@ -38,6 +38,7 @@ export interface TextMessage extends MessageUser {
|
|||
type: SendType.Text
|
||||
id: string
|
||||
body: string
|
||||
atUsers: AtUser[]
|
||||
}
|
||||
|
||||
export type RoomMessage = SyncUserMessage | LikeMessage | HateMessage | TextMessage
|
||||
|
@ -134,16 +135,17 @@ const RoomDomain = Remesh.domain({
|
|||
|
||||
const SendTextMessageCommand = domain.command({
|
||||
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 textMessage: TextMessage = {
|
||||
id: nanoid(),
|
||||
type: SendType.Text,
|
||||
body: message,
|
||||
body: typeof message === 'string' ? message : message.body,
|
||||
userId,
|
||||
username,
|
||||
userAvatar
|
||||
userAvatar,
|
||||
atUsers: typeof message === 'string' ? [] : message.atUsers
|
||||
}
|
||||
|
||||
const listMessage: NormalMessage = {
|
||||
|
@ -151,7 +153,8 @@ const RoomDomain = Remesh.domain({
|
|||
type: MessageType.Normal,
|
||||
date: Date.now(),
|
||||
likeUsers: [],
|
||||
hateUsers: []
|
||||
hateUsers: [],
|
||||
atUsers: typeof message === 'string' ? [] : message.atUsers
|
||||
}
|
||||
|
||||
peerRoom.sendMessage(textMessage)
|
||||
|
|
|
@ -12,6 +12,7 @@ export interface UserInfo {
|
|||
themeMode: 'system' | 'light' | 'dark'
|
||||
danmakuEnabled: boolean
|
||||
notificationEnabled: boolean
|
||||
notificationType: 'all' | 'at'
|
||||
}
|
||||
|
||||
const UserInfoDomain = Remesh.domain({
|
||||
|
|
|
@ -4,7 +4,7 @@ import localStorageDriver from 'unstorage/drivers/localstorage'
|
|||
import { LocalStorageExtern, IndexDBStorageExtern, BrowserSyncStorageExtern } from '@/domain/externs/Storage'
|
||||
import { STORAGE_NAME } from '@/constants/config'
|
||||
import { webExtensionDriver } from '@/utils/webExtensionDriver'
|
||||
import { browser } from 'wxt/browser'
|
||||
|
||||
import { Storage } from '@/domain/externs/Storage'
|
||||
import { EVENT } from '@/constants/event'
|
||||
|
||||
|
@ -62,23 +62,3 @@ export const BrowserSyncStorageImpl = BrowserSyncStorageExtern.impl({
|
|||
watch: browserSyncStorage.watch as Storage['watch'],
|
||||
unwatch: browserSyncStorage.unwatch
|
||||
})
|
||||
|
||||
// export const BrowserSyncStorageImpl = BrowserSyncStorageExtern.impl({
|
||||
// name: STORAGE_NAME,
|
||||
// get: async (key: string) => {
|
||||
// const res = await browser.storage.sync.get(key)
|
||||
// return res[key] ?? null
|
||||
// },
|
||||
// set: async (key, value) => {
|
||||
// await browser.storage.sync.set({ [key]: value ?? null })
|
||||
// },
|
||||
// remove: browserSyncStorage.removeItem,
|
||||
// clear: browserSyncStorage.clear,
|
||||
// watch: async (callback) => {
|
||||
// browser.storage.sync.onChanged.addListener(callback)
|
||||
// return async () => {
|
||||
// return browser.storage.sync.onChanged.removeListener(callback)
|
||||
// }
|
||||
// },
|
||||
// unwatch: browserSyncStorage.unwatch
|
||||
// })
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { type RemeshEvent, type RemeshAction, type RemeshDomainContext, type RemeshExtern } from 'remesh'
|
||||
import { defer, from, map, Observable, switchMap } from 'rxjs'
|
||||
import { from, map, Observable, switchMap } from 'rxjs'
|
||||
|
||||
import { Storage, StorageValue } from '@/domain/externs/Storage'
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
||||
export interface ResizableOptions {
|
||||
|
@ -11,7 +11,14 @@ export interface ResizableOptions {
|
|||
const useResizable = (options: ResizableOptions) => {
|
||||
const { minSize, maxSize, initSize = 0, direction } = options
|
||||
|
||||
const [size, setSize] = useState(initSize)
|
||||
const [size, setSize] = useState(clamp(initSize, minSize, maxSize))
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const newSize = clamp(initSize, minSize, maxSize)
|
||||
if (newSize !== size) {
|
||||
setSize(newSize)
|
||||
}
|
||||
}, [initSize, minSize, maxSize])
|
||||
|
||||
const position = useRef(0)
|
||||
|
||||
|
@ -67,13 +74,13 @@ const useResizable = (options: ResizableOptions) => {
|
|||
[isHorizontal]
|
||||
)
|
||||
|
||||
const ref = useRef<HTMLElement | null>(null)
|
||||
const handlerRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
// Watch ref: https://medium.com/@teh_builder/ref-objects-inside-useeffect-hooks-eb7c15198780
|
||||
const setRef = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (ref.current) {
|
||||
ref.current.removeEventListener('mousedown', handleStart)
|
||||
const setRef: RefCallback<HTMLElement | null> = useCallback(
|
||||
(node) => {
|
||||
if (handlerRef.current) {
|
||||
handlerRef.current.removeEventListener('mousedown', handleStart)
|
||||
document.removeEventListener('mouseup', handleEnd)
|
||||
document.removeEventListener('mousemove', handleMove)
|
||||
}
|
||||
|
@ -82,12 +89,12 @@ const useResizable = (options: ResizableOptions) => {
|
|||
document.addEventListener('mouseup', handleEnd)
|
||||
document.addEventListener('mousemove', handleMove)
|
||||
}
|
||||
ref.current = node
|
||||
handlerRef.current = node
|
||||
},
|
||||
[handleEnd, handleMove, handleStart]
|
||||
)
|
||||
|
||||
return { size, ref: setRef }
|
||||
return { size, setRef }
|
||||
}
|
||||
|
||||
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 { default as generateRandomAvatar } from './generateRandomAvatar'
|
||||
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)',
|
||||
opacity: '0'
|
||||
}
|
||||
},
|
||||
shimmer: {
|
||||
'0%': {
|
||||
'--shimmer-angle': '0deg'
|
||||
},
|
||||
'100%': {
|
||||
'--shimmer-angle': '360deg'
|
||||
}
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
|
|
Loading…
Reference in a new issue