Merge branch 'develop'

This commit is contained in:
molvqingtai 2024-10-28 09:32:20 +08:00
commit 165176b9a4
54 changed files with 10500 additions and 8274 deletions

View file

@ -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
View file

@ -14,4 +14,5 @@ web-ext.config.ts
*.pem
*.xpi
*.zip
.idea

View file

@ -1,2 +1,2 @@
pnpm lint-staged && pnpm tsc
pnpm lint-staged && pnpm check

View file

@ -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'
}
}
]

View file

@ -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"

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@ import Header from '@/app/content/views/Header'
import Footer from '@/app/content/views/Footer'
import Main from '@/app/content/views/Main'
import AppButton from '@/app/content/views/AppButton'
import AppContainer from '@/app/content/views/AppContainer'
import AppMain from '@/app/content/views/AppMain'
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
import RoomDomain from '@/domain/Room'
import UserInfoDomain from '@/domain/UserInfo'
@ -10,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>
)
)
}

View file

@ -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
)}
>

View file

@ -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)}
>

View file

@ -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"

View file

@ -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>

View file

@ -1,6 +1,5 @@
import { type FC } from 'react'
import { FrownIcon, ThumbsUpIcon } from 'lucide-react'
import { Badge } from '@/components/ui/Badge'
import LikeButton from './LikeButton'
import FormatDate from './FormatDate'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/Avatar'
@ -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)}

View file

@ -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}

View file

@ -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>

View file

@ -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>

View file

@ -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>
)

View file

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

View file

@ -0,0 +1,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

View file

@ -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>

View file

@ -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>

View file

@ -7,7 +7,6 @@ import PromptItem from '../../components/PromptItem'
import UserInfoDomain from '@/domain/UserInfo'
import RoomDomain, { MessageType } from '@/domain/Room'
import MessageListDomain from '@/domain/MessageList'
import BlurFade from '@/components/magicui/BlurFade'
const Main: FC = () => {
const send = useRemeshSend()
@ -39,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>

View file

@ -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())

View file

@ -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>
)
}

View file

@ -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>

View file

@ -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>
)
}

View file

@ -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>

View file

@ -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;
}

View file

@ -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;
} */

View file

@ -67,7 +67,7 @@ const Markdown: FC<MarkdownProps> = ({ children = '', className }) => {
input: ({ className, ...props }) => <input className={cn('my-0', className)} {...props} />,
table: ({ className, ...props }) => (
<div className="my-2 w-full">
<ScrollArea>
<ScrollArea scrollLock={false}>
<table className={cn('my-0 w-full rounded-md', className)} {...props} />
<ScrollBar orientation="horizontal" />
</ScrollArea>
@ -106,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>

View file

@ -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}
/>
))

View file

@ -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}

View file

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

View file

@ -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}

View file

@ -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>

View file

@ -9,11 +9,13 @@ import { map } from 'rxjs'
export interface AppStatus {
open: boolean
unread: number
position: { x: number; y: number }
}
export const defaultStatusState = {
open: false,
unread: 0
unread: 0,
position: { x: window.innerWidth - 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

View file

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

View file

@ -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 {

View file

@ -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
}
})
)

View file

@ -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)

View file

@ -12,6 +12,7 @@ export interface UserInfo {
themeMode: 'system' | 'light' | 'dark'
danmakuEnabled: boolean
notificationEnabled: boolean
notificationType: 'all' | 'at'
}
const UserInfoDomain = Remesh.domain({

View file

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

View file

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

View file

@ -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

View 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
View 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

View file

@ -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
View 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

View 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

View 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

View 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
View file

@ -0,0 +1,5 @@
export const getRootNode = () => {
return document.querySelector(__NAME__)?.shadowRoot?.querySelector('#app') || document.body
}
export default getRootNode

View 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

View file

@ -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'

View file

@ -81,6 +81,14 @@ export default {
transform: 'rotate(215deg) translateX(-500px)',
opacity: '0'
}
},
shimmer: {
'0%': {
'--shimmer-angle': '0deg'
},
'100%': {
'--shimmer-angle': '360deg'
}
}
},
animation: {