mirror of
https://github.com/molvqingtai/WebChat.git
synced 2025-11-25 19:27:34 +08:00
perf: optimize app positioning and message sending performance
- Change app button positioning from left-top to bottom-right anchor - Add reverse mode to useDraggable for bottom-right coordinate system - Reset button position to default on window resize - Use useMemo to cache message list computation - Use startTransition to prevent UI blocking when sending messages - Add generic type parameters to useShareRef and useTriggerAway - Add CLAUDE.md for repository guidance 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
218
CLAUDE.md
Normal file
218
CLAUDE.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Overview
|
||||
|
||||
WebChat is a decentralized, serverless browser extension that enables anonymous P2P chat on any website using WebRTC. Built with WXT framework for cross-browser support (Chrome, Firefox, Edge).
|
||||
|
||||
## Key Technologies
|
||||
|
||||
- **WXT**: Browser extension framework (config: `wxt.config.ts`)
|
||||
- **Remesh**: DDD framework for domain logic with true UI/logic separation (RxJS-based reactive state management)
|
||||
- **Artico (@rtco/client)**: WebRTC P2P communication library (replaces previous trystero dependency)
|
||||
- **React 19** with TypeScript
|
||||
- **Tailwind CSS v4** with shadcn/ui components
|
||||
- **Valibot**: Runtime schema validation
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
npm run dev # Chrome dev mode with hot reload
|
||||
npm run dev:firefox # Firefox dev mode
|
||||
|
||||
# Type checking
|
||||
npm run check # Run TypeScript compiler without emitting files
|
||||
|
||||
# Linting
|
||||
npm run lint # ESLint with auto-fix and cache
|
||||
|
||||
# Building
|
||||
npm run build # Production build for all browsers
|
||||
npm run build:chrome # Chrome production build only
|
||||
npm run build:firefox # Firefox production build only
|
||||
|
||||
# Packaging
|
||||
npm run pack # Create zip files for all browsers
|
||||
npm run pack:chrome # Create Chrome zip only
|
||||
npm run pack:firefox # Create Firefox zip only
|
||||
|
||||
# Maintenance
|
||||
npm run clear # Remove .output directory
|
||||
npm run prepare # Setup husky git hooks
|
||||
npm run postinstall # WXT preparation (auto-runs after install)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Extension Structure
|
||||
|
||||
WebChat uses WXT's app-based structure (not entrypoints):
|
||||
- **src/app/content/** - Content script injected into web pages (main chat UI)
|
||||
- **src/app/background/** - Service worker handling notifications and extension actions
|
||||
- **src/app/options/** - Options page UI for user profile settings
|
||||
- Entry files: `index.ts` or `index.tsx` in each app directory
|
||||
|
||||
### Domain-Driven Design (Remesh)
|
||||
|
||||
Business logic is fully decoupled from UI using Remesh domains:
|
||||
|
||||
**Core Domains** (`src/domain/`):
|
||||
- `ChatRoom.ts` - Site-specific P2P chat room (messages, users, sync)
|
||||
- `VirtualRoom.ts` - Global virtual room for cross-site user discovery
|
||||
- `MessageList.ts` - Message management and persistence
|
||||
- `UserInfo.ts` - User profile state
|
||||
- `AppStatus.ts` - Application state (open/minimized)
|
||||
- `Danmaku.ts` - Danmaku/bullet comments display
|
||||
- `Notification.ts` - Browser notifications
|
||||
- `Toast.ts` - In-app toast messages
|
||||
|
||||
**Domain Pattern**:
|
||||
- `domain/` - Remesh domain definitions (pure logic, queries, commands, events)
|
||||
- `domain/externs/` - External dependency interfaces (define contracts)
|
||||
- `domain/impls/` - Concrete implementations of externs (WebRTC, storage, etc.)
|
||||
- `domain/modules/` - Reusable domain sub-modules
|
||||
|
||||
### P2P Communication Architecture
|
||||
|
||||
**Two-Layer Room System**:
|
||||
|
||||
1. **ChatRoom** (Site-specific):
|
||||
- RoomId: Hash of current page's origin
|
||||
- Users on same site chat in isolated rooms
|
||||
- Message types: Text, Like, Hate, SyncUser, SyncHistory
|
||||
- History sync: Last 90 days (`SYNC_HISTORY_MAX_DAYS`)
|
||||
- Message size limit: 256KiB (`WEB_RTC_MAX_MESSAGE_SIZE`)
|
||||
|
||||
2. **VirtualRoom** (Global):
|
||||
- RoomId: `WEB_CHAT_VIRTUAL_ROOM` constant
|
||||
- Cross-site user discovery
|
||||
- Shares online user presence across different websites
|
||||
- Message types: SyncUser only
|
||||
|
||||
**Connection Flow**:
|
||||
1. User joins VirtualRoom (global presence)
|
||||
2. User joins ChatRoom (site-specific, based on `location.origin`)
|
||||
3. On peer join: Exchange SyncUser messages
|
||||
4. Sync message history if peer's lastMessageTime is older
|
||||
5. WebRTC data channels handle all message transport
|
||||
|
||||
### Storage Strategy
|
||||
|
||||
Three-tier storage implemented in `src/domain/impls/Storage.ts`:
|
||||
- **LocalStorage** - Fast, synchronous access (volatile)
|
||||
- **IndexDB** - Large data persistence (message history)
|
||||
- **BrowserSyncStorage** - Cross-device user profile sync (8kb limit per key)
|
||||
|
||||
Key storage keys in `src/constants/config.ts`:
|
||||
- `USER_INFO_STORAGE_KEY` - User profile (synced)
|
||||
- `MESSAGE_LIST_STORAGE_KEY` - Message history (IndexDB)
|
||||
- `APP_STATUS_STORAGE_KEY` - App UI state (local)
|
||||
|
||||
### Message Sync Logic
|
||||
|
||||
**Important sync behavior** (documented in ChatRoom.ts:337-355):
|
||||
- New peer joins → existing peers with newer messages push history
|
||||
- Only messages newer than peer's `lastMessageTime` are synced
|
||||
- Messages chunked to respect WebRTC size limits
|
||||
- Incremental sync (not full 90-day diff) - may result in incomplete history for peers joining at different times
|
||||
|
||||
## Code Organization
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # WXT applications (content, background, options)
|
||||
├── domain/ # Remesh domains (business logic)
|
||||
│ ├── externs/ # External dependency interfaces
|
||||
│ ├── impls/ # Concrete implementations
|
||||
│ └── modules/ # Reusable domain modules
|
||||
├── components/ # React UI components
|
||||
│ ├── ui/ # shadcn/ui base components
|
||||
│ └── magicui/ # Magic UI animated components
|
||||
├── utils/ # Pure utility functions
|
||||
├── constants/ # App constants and config
|
||||
├── hooks/ # React hooks
|
||||
├── messenger/ # Extension messaging (webext-bridge)
|
||||
├── lib/ # Third-party library integrations
|
||||
└── assets/ # Static assets (images, styles)
|
||||
```
|
||||
|
||||
## Path Aliases
|
||||
|
||||
TypeScript paths configured in `tsconfig.json`:
|
||||
- `@/*` → `./src/*`
|
||||
|
||||
Import example: `import { ChatRoomDomain } from '@/domain/ChatRoom'`
|
||||
|
||||
## Important Constants
|
||||
|
||||
In `src/constants/config.ts`:
|
||||
- `MESSAGE_MAX_LENGTH = 500` - Max message length
|
||||
- `MAX_AVATAR_SIZE = 5120` - Max avatar size (bytes) for sync storage
|
||||
- `SYNC_HISTORY_MAX_DAYS = 90` - Message history retention
|
||||
- `WEB_RTC_MAX_MESSAGE_SIZE = 262144` - 256KiB WebRTC limit
|
||||
- `VIRTUAL_ROOM_ID = 'WEB_CHAT_VIRTUAL_ROOM'` - Global room identifier
|
||||
|
||||
## Browser Extension Specifics
|
||||
|
||||
**Manifest configuration** (`wxt.config.ts`):
|
||||
- Permissions: `storage`, `notifications`, `tabs`
|
||||
- Matches: `https://*/*`
|
||||
- Excludes: localhost, 127.0.0.1, csdn.net, csdn.com
|
||||
- Browser-specific manifests for Chrome and Firefox
|
||||
|
||||
**Content Script Injection**:
|
||||
- Shadow DOM mode: `open`
|
||||
- Position: `inline` in body (appended last)
|
||||
- CSS isolation with `cssInjectionMode: 'ui'`
|
||||
- Event isolation: keyup, keydown, keypress
|
||||
|
||||
## Working with Remesh Domains
|
||||
|
||||
**Creating/Using Domains**:
|
||||
```typescript
|
||||
// In content script
|
||||
const store = Remesh.store({
|
||||
externs: [
|
||||
LocalStorageImpl,
|
||||
IndexDBStorageImpl,
|
||||
BrowserSyncStorageImpl,
|
||||
ChatRoomImpl,
|
||||
VirtualRoomImpl,
|
||||
// ... other implementations
|
||||
]
|
||||
})
|
||||
|
||||
// In React component
|
||||
const send = domain.useRemeshSend()
|
||||
const userList = domain.useRemeshQuery(chatRoomDomain.query.UserListQuery())
|
||||
|
||||
// Send command
|
||||
send(chatRoomDomain.command.SendTextMessageCommand('Hello'))
|
||||
|
||||
// Subscribe to event
|
||||
domain.useRemeshEvent(chatRoomDomain.event.OnTextMessageEvent, (message) => {
|
||||
console.log('New message:', message)
|
||||
})
|
||||
```
|
||||
|
||||
## Validation with Valibot
|
||||
|
||||
All runtime message validation uses Valibot (not Zod):
|
||||
```typescript
|
||||
import * as v from 'valibot'
|
||||
|
||||
const schema = v.object({ /* ... */ })
|
||||
const isValid = v.safeParse(schema, data).success
|
||||
```
|
||||
|
||||
## Linting & Git Hooks
|
||||
|
||||
- **Husky** pre-commit hooks configured
|
||||
- **lint-staged** auto-fixes JS/TS files on commit
|
||||
- **commitlint** enforces conventional commit messages
|
||||
|
||||
## Node Version
|
||||
|
||||
Minimum Node.js version: `>=20.0.0` (see `engines` in package.json)
|
||||
@@ -40,6 +40,12 @@ const AppButton: FC<AppButtonProps> = ({ className }) => {
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
// Get current window size to recalculate position on resize
|
||||
const windowSize = useWindowResize(({ width, height }) => {
|
||||
// Reset to default position when window resizes
|
||||
send(appStatusDomain.command.UpdatePositionCommand({ x: 50, y: 22 }))
|
||||
})
|
||||
|
||||
const {
|
||||
x,
|
||||
y,
|
||||
@@ -48,13 +54,10 @@ const AppButton: FC<AppButtonProps> = ({ className }) => {
|
||||
initX: appPosition.x,
|
||||
initY: appPosition.y,
|
||||
minX: 50,
|
||||
maxX: window.innerWidth - 50,
|
||||
maxY: window.innerHeight - 22,
|
||||
minY: 750
|
||||
})
|
||||
|
||||
useWindowResize(({ width, height }) => {
|
||||
send(appStatusDomain.command.UpdatePositionCommand({ x: width - 50, y: height - 22 }))
|
||||
maxX: windowSize.width - 50,
|
||||
maxY: windowSize.height - 22,
|
||||
minY: 750,
|
||||
reverse: true
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -87,11 +90,11 @@ const AppButton: FC<AppButtonProps> = ({ className }) => {
|
||||
return (
|
||||
<div
|
||||
ref={appMenuRef}
|
||||
className={cn('fixed bottom-5 right-5 z-infinity grid w-min select-none justify-center gap-y-3', className)}
|
||||
className={cn('fixed 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%)'
|
||||
right: `${x}px`,
|
||||
bottom: `${y}px`,
|
||||
transform: 'translateX(50%)'
|
||||
}}
|
||||
>
|
||||
<AnimatePresence>
|
||||
|
||||
@@ -16,9 +16,14 @@ const AppMain: FC<AppMainProps> = ({ children, className }) => {
|
||||
const appOpenStatus = useRemeshQuery(appStatusDomain.query.OpenQuery())
|
||||
const { x, y } = useRemeshQuery(appStatusDomain.query.PositionQuery())
|
||||
|
||||
const { width } = useWindowResize()
|
||||
const { width, height } = useWindowResize()
|
||||
|
||||
const isOnRightSide = x >= width / 2 + 50
|
||||
// Position x,y is offset from bottom-right corner
|
||||
// Convert to absolute position from left for comparison
|
||||
const absoluteX = width - x
|
||||
const absoluteY = height - y
|
||||
|
||||
const isOnRightSide = absoluteX >= width / 2 + 50
|
||||
|
||||
const { size, setRef } = useResizable({
|
||||
initSize: Math.max(375, width / 6),
|
||||
@@ -41,8 +46,8 @@ const AppMain: FC<AppMainProps> = ({ children, className }) => {
|
||||
onAnimationStart={() => setAnimationComplete(false)}
|
||||
style={{
|
||||
width: `${size}px`,
|
||||
left: `${x}px`,
|
||||
bottom: `calc(100vh - ${y}px + 22px)`
|
||||
left: `${absoluteX}px`,
|
||||
bottom: `calc(100vh - ${absoluteY}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)] min-h-[375px] grid-flow-col grid-rows-[auto_1fr_auto] rounded-xl bg-slate-50 dark:bg-slate-950 font-sans shadow-2xl`,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ChangeEvent, KeyboardEvent, ClipboardEvent } from 'react'
|
||||
import { useMemo, useRef, useState, type FC } from 'react'
|
||||
import { useMemo, useRef, useState, startTransition, type FC } from 'react'
|
||||
import { CornerDownLeftIcon } from 'lucide-react'
|
||||
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
||||
import MessageInput from '../../components/message-input'
|
||||
@@ -40,13 +40,15 @@ const Footer: FC = () => {
|
||||
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 { setRef: setAutoCompleteListRef } = useTriggerAway<HTMLDivElement>(['click'], () =>
|
||||
setAutoCompleteListShow(false)
|
||||
)
|
||||
const shareAutoCompleteListRef = useShareRef<HTMLDivElement>(setAutoCompleteListRef, autoCompleteListRef)
|
||||
const isComposing = useRef(false)
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
||||
const [inputLoading, setInputLoading] = useState(false)
|
||||
|
||||
const shareRef = useShareRef(inputRef, setRef)
|
||||
const shareRef = useShareRef<HTMLTextAreaElement | null>(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.
|
||||
@@ -146,8 +148,13 @@ const Footer: FC = () => {
|
||||
return send(toastDomain.command.WarningCommand('Message size cannot exceed 256KiB.'))
|
||||
}
|
||||
|
||||
send(chatRoomDomain.command.SendTextMessageCommand({ body: transformedMessage, atUsers }))
|
||||
send(messageInputDomain.command.ClearCommand())
|
||||
// Send message with lower priority to prevent blocking UI
|
||||
startTransition(() => {
|
||||
send([
|
||||
chatRoomDomain.command.SendTextMessageCommand({ body: transformedMessage, atUsers }),
|
||||
messageInputDomain.command.ClearCommand()
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type FC } from 'react'
|
||||
import { type FC, useMemo } from 'react'
|
||||
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
||||
|
||||
import MessageList from '../../components/message-list'
|
||||
@@ -15,18 +15,23 @@ const Main: FC = () => {
|
||||
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
|
||||
const _messageList = useRemeshQuery(messageListDomain.query.ListQuery())
|
||||
const messageList = _messageList
|
||||
.map((message) => {
|
||||
if (message.type === MessageType.Normal) {
|
||||
return {
|
||||
...message,
|
||||
like: message.likeUsers.some((likeUser) => likeUser.userId === userInfo?.id),
|
||||
hate: message.hateUsers.some((hateUser) => hateUser.userId === userInfo?.id)
|
||||
}
|
||||
}
|
||||
return message
|
||||
})
|
||||
.toSorted((a, b) => a.sendTime - b.sendTime)
|
||||
|
||||
const messageList = useMemo(
|
||||
() =>
|
||||
_messageList
|
||||
.map((message) => {
|
||||
if (message.type === MessageType.Normal) {
|
||||
return {
|
||||
...message,
|
||||
like: message.likeUsers.some((likeUser) => likeUser.userId === userInfo?.id),
|
||||
hate: message.hateUsers.some((hateUser) => hateUser.userId === userInfo?.id)
|
||||
}
|
||||
}
|
||||
return message
|
||||
})
|
||||
.toSorted((a, b) => a.sendTime - b.sendTime),
|
||||
[_messageList, userInfo?.id]
|
||||
)
|
||||
|
||||
const handleLikeChange = (messageId: string) => {
|
||||
send(chatRoomDomain.command.SendLikeMessageCommand(messageId))
|
||||
|
||||
@@ -12,10 +12,11 @@ export interface AppStatus {
|
||||
position: { x: number; y: number }
|
||||
}
|
||||
|
||||
// Position is stored as offset from bottom-right corner
|
||||
export const defaultStatusState = {
|
||||
open: false,
|
||||
unread: 0,
|
||||
position: { x: window.innerWidth - 50, y: window.innerHeight - 22 }
|
||||
position: { x: 50, y: 22 }
|
||||
}
|
||||
|
||||
const AppStatusDomain = Remesh.domain({
|
||||
|
||||
@@ -8,24 +8,46 @@ export interface DargOptions {
|
||||
minX: number
|
||||
maxY: number
|
||||
minY: number
|
||||
reverse?: boolean // If true, position is calculated from bottom-right corner
|
||||
}
|
||||
|
||||
const useDraggable = (options: DargOptions) => {
|
||||
const { initX, initY, maxX = 0, minX = 0, maxY = 0, minY = 0 } = options
|
||||
const { initX, initY, maxX = 0, minX = 0, maxY = 0, minY = 0, reverse = false } = options
|
||||
|
||||
const mousePosition = useRef({ x: 0, y: 0 })
|
||||
const positionRef = useRef({ x: clamp(initX, minX, maxX), y: clamp(initY, minY, maxY) })
|
||||
const [position, setPosition] = useState(positionRef.current)
|
||||
|
||||
// Convert to internal coordinates if reverse mode
|
||||
const toInternal = (x: number, y: number) => {
|
||||
if (!reverse) return { x, y }
|
||||
return {
|
||||
x: window.innerWidth - x,
|
||||
y: window.innerHeight - y
|
||||
}
|
||||
}
|
||||
|
||||
// Convert from internal coordinates if reverse mode
|
||||
const fromInternal = (x: number, y: number) => {
|
||||
if (!reverse) return { x, y }
|
||||
return {
|
||||
x: window.innerWidth - x,
|
||||
y: window.innerHeight - y
|
||||
}
|
||||
}
|
||||
|
||||
const internalInit = toInternal(initX, initY)
|
||||
const positionRef = useRef({ x: clamp(internalInit.x, minX, maxX), y: clamp(internalInit.y, minY, maxY) })
|
||||
const [position, setPosition] = useState(() => fromInternal(positionRef.current.x, positionRef.current.y))
|
||||
|
||||
useEffect(() => {
|
||||
const newPosition = { x: clamp(initX, minX, maxX), y: clamp(initY, minY, maxY) }
|
||||
if (JSON.stringify(newPosition) !== JSON.stringify(position)) {
|
||||
const internal = toInternal(initX, initY)
|
||||
const newPosition = { x: clamp(internal.x, minX, maxX), y: clamp(internal.y, minY, maxY) }
|
||||
if (JSON.stringify(newPosition) !== JSON.stringify(positionRef.current)) {
|
||||
startTransition(() => {
|
||||
positionRef.current = newPosition
|
||||
setPosition(newPosition)
|
||||
setPosition(fromInternal(newPosition.x, newPosition.y))
|
||||
})
|
||||
}
|
||||
}, [initX, initY, maxX, minX, maxY, minY])
|
||||
}, [initX, initY, maxX, minX, maxY, minY, reverse])
|
||||
|
||||
const isMove = useRef(false)
|
||||
|
||||
@@ -52,12 +74,12 @@ const useDraggable = (options: DargOptions) => {
|
||||
const y = clamp(delta.y, minY, maxY)
|
||||
startTransition(() => {
|
||||
positionRef.current = { x, y }
|
||||
setPosition({ x, y })
|
||||
setPosition(fromInternal(x, y))
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
[minX, maxX, minY, maxY]
|
||||
[minX, maxX, minY, maxY, reverse]
|
||||
)
|
||||
|
||||
const handleEnd = useCallback(() => {
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import type { ForwardedRef, MutableRefObject, RefCallback } from 'react'
|
||||
import type { Ref } from 'react'
|
||||
import { 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
|
||||
}
|
||||
}),
|
||||
export const setRef = <T>(ref: Ref<T> | undefined, value: T) => {
|
||||
if (typeof ref === 'function') {
|
||||
return ref(value)
|
||||
} else if (ref !== null && ref !== undefined) {
|
||||
ref.current = value
|
||||
}
|
||||
}
|
||||
|
||||
const useShareRef = <T>(...refs: (Ref<T> | undefined)[]) => {
|
||||
return useCallback(
|
||||
(node: T) => {
|
||||
const cleanups = refs.map((ref) => setRef(ref, node))
|
||||
return () =>
|
||||
cleanups.forEach((cleanup, index) => (typeof cleanup === 'function' ? cleanup() : setRef(refs[index], null)))
|
||||
},
|
||||
[...refs]
|
||||
)
|
||||
|
||||
return setRef
|
||||
}
|
||||
|
||||
export default useShareRef
|
||||
|
||||
@@ -6,8 +6,11 @@ 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 useTriggerAway = <T extends Element = Element, E extends Event = Event>(
|
||||
events: Events,
|
||||
callback: (event: E) => void
|
||||
) => {
|
||||
const handleRef = useRef<T | null>(null)
|
||||
|
||||
const handler = (event: SafeAny) => {
|
||||
const rootNode = handleRef.current?.getRootNode()
|
||||
@@ -22,7 +25,7 @@ const useTriggerAway = <E extends Event = Event>(events: Events, callback: (even
|
||||
* | | |
|
||||
* |- on(document) -|- on(shadowRoot) -|
|
||||
*/
|
||||
const setRef: RefCallback<HTMLElement | null> = useCallback(
|
||||
const setRef: RefCallback<T> = useCallback(
|
||||
(node) => {
|
||||
if (handleRef.current) {
|
||||
const rootNode = handleRef.current.getRootNode()
|
||||
|
||||
Reference in New Issue
Block a user