feat: support @user syntax
This commit is contained in:
parent
4eba638a36
commit
bef576a77b
31 changed files with 722 additions and 202 deletions
2
.github/workflows/cd.yml
vendored
2
.github/workflows/cd.yml
vendored
|
@ -21,7 +21,7 @@ jobs:
|
||||||
- run: pnpm install --ignore-scripts
|
- run: pnpm install --ignore-scripts
|
||||||
- run: pnpm wxt prepare
|
- run: pnpm wxt prepare
|
||||||
- run: pnpm run lint
|
- run: pnpm run lint
|
||||||
- run: pnpm run tsc
|
- run: pnpm run check
|
||||||
|
|
||||||
release:
|
release:
|
||||||
needs: linter
|
needs: linter
|
||||||
|
|
|
@ -1,2 +1,2 @@
|
||||||
|
|
||||||
pnpm lint-staged && pnpm tsc
|
pnpm lint-staged && pnpm check
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Linter } from 'eslint'
|
// import type { Linter } from 'eslint'
|
||||||
import globals from 'globals'
|
import globals from 'globals'
|
||||||
import pluginJs from '@eslint/js'
|
import pluginJs from '@eslint/js'
|
||||||
import tseslint from 'typescript-eslint'
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
"pack:firefox": "wxt zip -b firefox",
|
"pack:firefox": "wxt zip -b firefox",
|
||||||
"lint": "eslint --fix --flag unstable_ts_config",
|
"lint": "eslint --fix --flag unstable_ts_config",
|
||||||
"clear": "rimraf .output",
|
"clear": "rimraf .output",
|
||||||
"tsc": "tsc --noEmit",
|
"check": "tsc --noEmit",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"postinstall": "wxt prepare"
|
"postinstall": "wxt prepare"
|
||||||
},
|
},
|
||||||
|
@ -54,6 +54,8 @@
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
"@radix-ui/react-popover": "^1.1.2",
|
"@radix-ui/react-popover": "^1.1.2",
|
||||||
|
"@radix-ui/react-portal": "^1.1.2",
|
||||||
|
"@radix-ui/react-presence": "^1.1.1",
|
||||||
"@radix-ui/react-radio-group": "^1.2.1",
|
"@radix-ui/react-radio-group": "^1.2.1",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.0",
|
"@radix-ui/react-scroll-area": "^1.2.0",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
|
|
|
@ -38,6 +38,12 @@ importers:
|
||||||
'@radix-ui/react-popover':
|
'@radix-ui/react-popover':
|
||||||
specifier: ^1.1.2
|
specifier: ^1.1.2
|
||||||
version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
'@radix-ui/react-portal':
|
||||||
|
specifier: ^1.1.2
|
||||||
|
version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
'@radix-ui/react-presence':
|
||||||
|
specifier: ^1.1.1
|
||||||
|
version: 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
'@radix-ui/react-radio-group':
|
'@radix-ui/react-radio-group':
|
||||||
specifier: ^1.2.1
|
specifier: ^1.2.1
|
||||||
version: 1.2.1(@types/react-dom@18.3.1)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 1.2.1(@types/react-dom@18.3.1)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
import { forwardRef, type ChangeEvent, CompositionEvent, type KeyboardEvent } from 'react'
|
import { forwardRef, type ChangeEvent, CompositionEvent, type KeyboardEvent } from 'react'
|
||||||
|
|
||||||
import { Textarea } from '@/components/ui/Textarea'
|
|
||||||
import { Markdown } from '@/components/Markdown'
|
|
||||||
import { cn } from '@/utils'
|
import { cn } from '@/utils'
|
||||||
|
import { Textarea } from '@/components/ui/Textarea'
|
||||||
import { ScrollArea } from '@/components/ui/ScrollArea'
|
import { ScrollArea } from '@/components/ui/ScrollArea'
|
||||||
|
|
||||||
export interface MessageInputProps {
|
export interface MessageInputProps {
|
||||||
|
@ -13,11 +12,16 @@ export interface MessageInputProps {
|
||||||
autoFocus?: boolean
|
autoFocus?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
onInput?: (e: ChangeEvent<HTMLTextAreaElement>) => void
|
onInput?: (e: ChangeEvent<HTMLTextAreaElement>) => void
|
||||||
onEnter?: (e: KeyboardEvent<HTMLTextAreaElement>) => void
|
onKeyDown?: (e: KeyboardEvent<HTMLTextAreaElement>) => void
|
||||||
onCompositionStart?: (e: CompositionEvent<HTMLTextAreaElement>) => void
|
onCompositionStart?: (e: CompositionEvent<HTMLTextAreaElement>) => void
|
||||||
onCompositionEnd?: (e: CompositionEvent<HTMLTextAreaElement>) => void
|
onCompositionEnd?: (e: CompositionEvent<HTMLTextAreaElement>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Need @ syntax highlighting? Waiting for textarea to support Highlight API
|
||||||
|
*
|
||||||
|
* @see https://github.com/w3c/csswg-drafts/issues/4603
|
||||||
|
*/
|
||||||
const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
|
@ -25,36 +29,26 @@ const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
||||||
className,
|
className,
|
||||||
maxLength = 500,
|
maxLength = 500,
|
||||||
onInput,
|
onInput,
|
||||||
onEnter,
|
onKeyDown,
|
||||||
onCompositionStart,
|
onCompositionStart,
|
||||||
onCompositionEnd,
|
onCompositionEnd,
|
||||||
preview,
|
|
||||||
autoFocus,
|
autoFocus,
|
||||||
disabled
|
disabled
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
||||||
if (e.key === 'Enter' && !(e.shiftKey || e.ctrlKey || e.altKey || e.metaKey)) {
|
|
||||||
e.preventDefault()
|
|
||||||
onEnter?.(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('relative', className)}>
|
<div className={cn('relative', className)}>
|
||||||
{preview ? (
|
|
||||||
<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">
|
<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
|
<Textarea
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
maxLength={maxLength}
|
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"
|
||||||
rows={2}
|
rows={2}
|
||||||
value={value}
|
value={value}
|
||||||
|
spellCheck={false}
|
||||||
onCompositionStart={onCompositionStart}
|
onCompositionStart={onCompositionStart}
|
||||||
onCompositionEnd={onCompositionEnd}
|
onCompositionEnd={onCompositionEnd}
|
||||||
placeholder="Type your message here."
|
placeholder="Type your message here."
|
||||||
|
@ -62,7 +56,6 @@ const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</ScrollArea>
|
</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">
|
||||||
{value?.length ?? 0}/{maxLength}
|
{value?.length ?? 0}/{maxLength}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -25,6 +25,24 @@ const MessageItem: FC<MessageItemProps> = (props) => {
|
||||||
const handleHateChange = (checked: boolean) => {
|
const handleHateChange = (checked: boolean) => {
|
||||||
props.onHateChange?.(checked)
|
props.onHateChange?.(checked)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let content = props.data.body
|
||||||
|
|
||||||
|
// Check if the field exists, compatible with old data
|
||||||
|
if (props.data.atUsers) {
|
||||||
|
const atUserPositions = props.data.atUsers.flatMap((user) =>
|
||||||
|
user.positions.map((position) => ({ username: user.username, userId: user.userId, position }))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Replace from back to front according to position to avoid affecting previous indices
|
||||||
|
atUserPositions
|
||||||
|
.sort((a, b) => b.position[0] - a.position[0])
|
||||||
|
.forEach(({ position, username }) => {
|
||||||
|
const [start, end] = position
|
||||||
|
content = `${content.slice(0, start)} **@${username}** ${content.slice(end + 1)}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-index={props.index}
|
data-index={props.index}
|
||||||
|
@ -41,7 +59,7 @@ const MessageItem: FC<MessageItemProps> = (props) => {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="pb-2">
|
<div className="pb-2">
|
||||||
<Markdown>{props.data.body}</Markdown>
|
<Markdown>{content}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-flow-col justify-end gap-x-2 leading-none">
|
<div className="grid grid-flow-col justify-end gap-x-2 leading-none">
|
||||||
<LikeButton
|
<LikeButton
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { type FC, useState, type MouseEvent, useRef, useEffect, useLayoutEffect } from 'react'
|
import { type FC, useState, type MouseEvent, useLayoutEffect } from 'react'
|
||||||
import { SettingsIcon, MoonIcon, SunIcon, HandIcon } from 'lucide-react'
|
import { SettingsIcon, MoonIcon, SunIcon, HandIcon } from 'lucide-react'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@ import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { EVENT } from '@/constants/event'
|
import { EVENT } from '@/constants/event'
|
||||||
import UserInfoDomain from '@/domain/UserInfo'
|
import UserInfoDomain from '@/domain/UserInfo'
|
||||||
import useClickAway from '@/hooks/useClickAway'
|
import useTriggerAway from '@/hooks/useTriggerAway'
|
||||||
import { checkSystemDarkMode, cn } from '@/utils'
|
import { checkSystemDarkMode, cn } from '@/utils'
|
||||||
import ToastDomain from '@/domain/Toast'
|
import ToastDomain from '@/domain/Toast'
|
||||||
import LogoIcon0 from '@/assets/images/logo-0.svg'
|
import LogoIcon0 from '@/assets/images/logo-0.svg'
|
||||||
|
@ -40,11 +40,13 @@ const AppButton: FC = () => {
|
||||||
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
const { width, height } = useWindowSize()
|
const { width, height } = useWindowSize()
|
||||||
|
|
||||||
const { x, y, ref } = useDarg({
|
const {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
setRef: appButtonRef
|
||||||
|
} = useDarg({
|
||||||
initX: appPosition.x,
|
initX: appPosition.x,
|
||||||
initY: appPosition.y,
|
initY: appPosition.y,
|
||||||
minX: 44,
|
minX: 44,
|
||||||
|
@ -57,9 +59,7 @@ const AppButton: FC = () => {
|
||||||
appStatusLoadIsFinished && send(appStatusDomain.command.UpdatePositionCommand({ x, y }))
|
appStatusLoadIsFinished && send(appStatusDomain.command.UpdatePositionCommand({ x, y }))
|
||||||
}, [x, y])
|
}, [x, y])
|
||||||
|
|
||||||
useClickAway(menuRef, () => {
|
const { setRef: appMenuRef } = useTriggerAway(['click'], () => setMenuOpen(false))
|
||||||
setMenuOpen(false)
|
|
||||||
}, ['click'])
|
|
||||||
|
|
||||||
const handleToggleMenu = (e: MouseEvent<HTMLButtonElement>) => {
|
const handleToggleMenu = (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
@ -85,7 +85,7 @@ const AppButton: FC = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
ref={appMenuRef}
|
||||||
className="fixed bottom-5 right-5 z-infinity grid w-min select-none justify-center gap-y-3"
|
className="fixed bottom-5 right-5 z-infinity grid w-min select-none justify-center gap-y-3"
|
||||||
style={{
|
style={{
|
||||||
left: `calc(${appPosition.x}px)`,
|
left: `calc(${appPosition.x}px)`,
|
||||||
|
@ -122,7 +122,7 @@ const AppButton: FC = () => {
|
||||||
<Button onClick={handleOpenOptionsPage} variant="outline" className="size-10 rounded-full p-0 shadow">
|
<Button onClick={handleOpenOptionsPage} variant="outline" className="size-10 rounded-full p-0 shadow">
|
||||||
<SettingsIcon size={20} />
|
<SettingsIcon size={20} />
|
||||||
</Button>
|
</Button>
|
||||||
<Button ref={ref} variant="outline" className="size-10 cursor-grab rounded-full p-0 shadow">
|
<Button ref={appButtonRef} variant="outline" className="size-10 cursor-grab rounded-full p-0 shadow">
|
||||||
<HandIcon size={20} />
|
<HandIcon size={20} />
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
|
@ -19,7 +19,7 @@ const AppMain: FC<AppMainProps> = ({ children }) => {
|
||||||
|
|
||||||
const isOnRightSide = x >= width / 2 + 44
|
const isOnRightSide = x >= width / 2 + 44
|
||||||
|
|
||||||
const { size, ref } = useResizable({
|
const { size, setRef } = useResizable({
|
||||||
initSize: Math.max(375, width / 6),
|
initSize: Math.max(375, width / 6),
|
||||||
maxSize: Math.max(Math.min(750, width / 3), 375),
|
maxSize: Math.max(Math.min(750, width / 3), 375),
|
||||||
minSize: Math.max(375, width / 6),
|
minSize: Math.max(375, width / 6),
|
||||||
|
@ -50,7 +50,7 @@ const AppMain: FC<AppMainProps> = ({ children }) => {
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={setRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-y-3 z-20 w-1 cursor-ew-resize rounded-xl bg-slate-100 opacity-0 shadow transition-opacity duration-200 ease-in hover:opacity-100',
|
'absolute inset-y-3 z-20 w-1 cursor-ew-resize rounded-xl bg-slate-100 opacity-0 shadow transition-opacity duration-200 ease-in hover:opacity-100',
|
||||||
isOnRightSide ? '-left-0.5' : '-right-0.5'
|
isOnRightSide ? '-left-0.5' : '-right-0.5'
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { ChangeEvent, useRef, type FC } from 'react'
|
import { ChangeEvent, useMemo, useRef, useState, KeyboardEvent, type FC } from 'react'
|
||||||
import { CornerDownLeftIcon } from 'lucide-react'
|
import { CornerDownLeftIcon } from 'lucide-react'
|
||||||
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
||||||
import MessageInput from '../../components/MessageInput'
|
import MessageInput from '../../components/MessageInput'
|
||||||
|
@ -7,48 +7,289 @@ import { Button } from '@/components/ui/Button'
|
||||||
import MessageInputDomain from '@/domain/MessageInput'
|
import MessageInputDomain from '@/domain/MessageInput'
|
||||||
import { MESSAGE_MAX_LENGTH } from '@/constants/config'
|
import { MESSAGE_MAX_LENGTH } from '@/constants/config'
|
||||||
import RoomDomain from '@/domain/Room'
|
import RoomDomain from '@/domain/Room'
|
||||||
|
import useCursorPosition from '@/hooks/useCursorPosition'
|
||||||
|
import useShareRef from '@/hooks/useShareRef'
|
||||||
|
import { Presence } from '@radix-ui/react-presence'
|
||||||
|
import { Portal } from '@radix-ui/react-portal'
|
||||||
|
import useTriggerAway from '@/hooks/useTriggerAway'
|
||||||
|
import { ScrollArea } from '@/components/ui/ScrollArea'
|
||||||
|
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'
|
||||||
|
import UserInfoDomain from '@/domain/UserInfo'
|
||||||
|
import { cn, getTextSimilarity } from '@/utils'
|
||||||
|
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
|
||||||
|
import { AvatarImage } from '@radix-ui/react-avatar'
|
||||||
|
import ToastDomain from '@/domain/Toast'
|
||||||
|
|
||||||
const Footer: FC = () => {
|
const Footer: FC = () => {
|
||||||
const send = useRemeshSend()
|
const send = useRemeshSend()
|
||||||
|
const toastDomain = useRemeshDomain(ToastDomain())
|
||||||
const roomDomain = useRemeshDomain(RoomDomain())
|
const roomDomain = useRemeshDomain(RoomDomain())
|
||||||
const messageInputDomain = useRemeshDomain(MessageInputDomain())
|
const messageInputDomain = useRemeshDomain(MessageInputDomain())
|
||||||
const message = useRemeshQuery(messageInputDomain.query.MessageQuery())
|
const message = useRemeshQuery(messageInputDomain.query.MessageQuery())
|
||||||
|
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||||
|
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
|
||||||
|
const userList = useRemeshQuery(roomDomain.query.UserListQuery())
|
||||||
|
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const isComposing = useRef(false)
|
const { x, y, selectionStart, selectionEnd, setRef } = useCursorPosition()
|
||||||
|
|
||||||
const handleInput = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
const [autoCompleteListShow, setAutoCompleteListShow] = useState(false)
|
||||||
send(messageInputDomain.command.InputCommand(e.target.value))
|
const [scrollParentRef, setScrollParentRef] = useState<HTMLDivElement | null>(null)
|
||||||
|
const autoCompleteListRef = useRef<HTMLDivElement>(null)
|
||||||
|
const { setRef: setAutoCompleteListRef } = useTriggerAway(['click'], () => setAutoCompleteListShow(false))
|
||||||
|
const shareAutoCompleteListRef = useShareRef(setAutoCompleteListRef, autoCompleteListRef)
|
||||||
|
const isComposing = useRef(false)
|
||||||
|
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
||||||
|
|
||||||
|
const shareRef = useShareRef(inputRef, setRef)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When inserting a username using the @ syntax, record the username's position information and the mapping relationship between the position information and userId to distinguish between users with the same name.
|
||||||
|
*/
|
||||||
|
const atUserRecord = useRef<Map<string, Set<[number, number]>>>(new Map())
|
||||||
|
|
||||||
|
const updateAtUserAtRecord = useMemo(
|
||||||
|
() => (message: string, start: number, end: number, offset: number, atUserId?: string) => {
|
||||||
|
const positions: [number, number] = [start, end]
|
||||||
|
|
||||||
|
// If the editing position is before the end position of @user, update the editing position.
|
||||||
|
// "@user" => "E@user"
|
||||||
|
// "@user" => "@useEr"
|
||||||
|
// "@user" => "@user @user"
|
||||||
|
atUserRecord.current.forEach((item, userId) => {
|
||||||
|
const positionList = [...item].map<[number, number]>((item) => {
|
||||||
|
const inBefore = Math.min(start, end) <= item[1]
|
||||||
|
return inBefore ? [item[0] + offset + (end - start), item[1] + offset + (end - start)] : item
|
||||||
|
})
|
||||||
|
atUserRecord.current.set(userId, new Set(positionList))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Insert a new @user record
|
||||||
|
if (atUserId) {
|
||||||
|
atUserRecord.current.set(atUserId, atUserRecord.current.get(atUserId)?.add(positions) ?? new Set([positions]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After moving, check if the @user in the message matches the saved position record. If not, it means the @user has been edited, so delete that record.
|
||||||
|
// Filter out records where the stored position does not match the actual position.
|
||||||
|
atUserRecord.current.forEach((item, userId) => {
|
||||||
|
// Pre-calculate the offset after InputCommand
|
||||||
|
const positionList = [...item].filter((item) => {
|
||||||
|
const username = message.slice(item[0], item[1] + 1)
|
||||||
|
return username === `@${userList.find((user) => user.userId === userId)?.username}`
|
||||||
|
})
|
||||||
|
if (positionList.length) {
|
||||||
|
atUserRecord.current.set(userId, new Set(positionList))
|
||||||
|
} else {
|
||||||
|
atUserRecord.current.delete(userId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[userList]
|
||||||
|
)
|
||||||
|
|
||||||
|
const [selectedUserIndex, setSelectedUserIndex] = useState(0)
|
||||||
|
const [searchNameKeyword, setSearchNameKeyword] = useState('')
|
||||||
|
|
||||||
|
const autoCompleteList = useMemo(() => {
|
||||||
|
return userList
|
||||||
|
.filter((user) => user.userId !== userInfo?.id)
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
similarity: getTextSimilarity(searchNameKeyword.toLowerCase(), item.username.toLowerCase())
|
||||||
|
}))
|
||||||
|
.toSorted((a, b) => b.similarity - a.similarity)
|
||||||
|
}, [searchNameKeyword, userList, userInfo])
|
||||||
|
|
||||||
|
const selectedUser = autoCompleteList.find((_, index) => index === selectedUserIndex)!
|
||||||
|
|
||||||
const handleSend = () => {
|
const handleSend = () => {
|
||||||
if (isComposing.current) return
|
if (!`${message}`.trim()) {
|
||||||
if (!message.trim()) return
|
return send(toastDomain.command.WarningCommand('Message cannot be empty.'))
|
||||||
send(roomDomain.command.SendTextMessageCommand(message.trim()))
|
}
|
||||||
|
|
||||||
|
const atUsers = [...atUserRecord.current]
|
||||||
|
.map(([userId, positions]) => {
|
||||||
|
const user = userList.find((user) => user.userId === userId)
|
||||||
|
return (user ? { ...user, positions: [...positions] } : undefined)!
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
send(roomDomain.command.SendTextMessageCommand({ body: message, atUsers }))
|
||||||
send(messageInputDomain.command.ClearCommand())
|
send(messageInputDomain.command.ClearCommand())
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEmojiSelect = (emoji: string) => {
|
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
send(messageInputDomain.command.InputCommand(`${message}${emoji}`))
|
if (autoCompleteListShow && autoCompleteList.length) {
|
||||||
inputRef.current?.focus()
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||||
|
const length = autoCompleteList.length
|
||||||
|
const prevIndex = selectedUserIndex
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
const index = (prevIndex + 1) % length
|
||||||
|
setSelectedUserIndex(index)
|
||||||
|
virtuosoRef.current?.scrollIntoView({ index })
|
||||||
|
e.preventDefault()
|
||||||
}
|
}
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
const index = (prevIndex - 1 + length) % length
|
||||||
|
setSelectedUserIndex(index)
|
||||||
|
virtuosoRef.current?.scrollIntoView({ index })
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['Escape', 'Backspace', 'Delete', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||||
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
|
const isDeleteAt = message.at(selectionStart - 1) === '@'
|
||||||
|
setAutoCompleteListShow(!isDeleteAt)
|
||||||
|
} else {
|
||||||
|
setAutoCompleteListShow(false)
|
||||||
|
}
|
||||||
|
setSelectedUserIndex(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Enter' && !(e.shiftKey || e.ctrlKey || e.altKey || e.metaKey)) {
|
||||||
|
if (isComposing.current) return
|
||||||
|
|
||||||
|
if (autoCompleteListShow && autoCompleteList.length) {
|
||||||
|
handleInjectAtSyntax(selectedUser.username)
|
||||||
|
} else {
|
||||||
|
handleSend()
|
||||||
|
}
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInput = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const currentMessage = e.target.value
|
||||||
|
|
||||||
|
if (autoCompleteListShow) {
|
||||||
|
const target = e.target as HTMLTextAreaElement
|
||||||
|
if (target.value) {
|
||||||
|
const atIndex = target.value.lastIndexOf('@', selectionEnd - 1)
|
||||||
|
if (atIndex !== -1) {
|
||||||
|
const keyword = target.value.slice(atIndex + 1, selectionEnd)
|
||||||
|
setSearchNameKeyword(keyword)
|
||||||
|
setSelectedUserIndex(0)
|
||||||
|
virtuosoRef.current?.scrollIntoView({ index: 0 })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setAutoCompleteListShow(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = e.nativeEvent as InputEvent
|
||||||
|
|
||||||
|
if (event.data === '@' && autoCompleteList.length) {
|
||||||
|
setAutoCompleteListShow(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-calculate the offset after InputCommand
|
||||||
|
const start = selectionStart
|
||||||
|
const end = selectionStart + currentMessage.length - message.length
|
||||||
|
|
||||||
|
updateAtUserAtRecord(currentMessage, start, end, 0)
|
||||||
|
|
||||||
|
send(messageInputDomain.command.InputCommand(currentMessage))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInjectEmoji = (emoji: string) => {
|
||||||
|
const newMessage = `${message.slice(0, selectionEnd)}${emoji}${message.slice(selectionEnd)}`
|
||||||
|
|
||||||
|
// Pre-calculate the offset after InputCommand
|
||||||
|
const start = selectionStart
|
||||||
|
const end = selectionEnd + newMessage.length - message.length
|
||||||
|
|
||||||
|
updateAtUserAtRecord(newMessage, start, end, 0)
|
||||||
|
|
||||||
|
send(messageInputDomain.command.InputCommand(newMessage))
|
||||||
|
|
||||||
|
requestIdleCallback(() => {
|
||||||
|
inputRef.current?.setSelectionRange(end, end)
|
||||||
|
inputRef.current?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInjectAtSyntax = (username: string) => {
|
||||||
|
const atIndex = message.lastIndexOf('@', selectionEnd - 1)
|
||||||
|
// Determine if there is a space before @
|
||||||
|
const hasBeforeSpace = message.slice(atIndex - 1, atIndex) === ' '
|
||||||
|
const hasAfterSpace = message.slice(selectionEnd, selectionEnd + 1) === ' '
|
||||||
|
|
||||||
|
const atText = `${hasBeforeSpace ? '' : ' '}@${username}${hasAfterSpace ? '' : ' '}`
|
||||||
|
const newMessage = message.slice(0, atIndex) + `${atText}` + message.slice(selectionEnd)
|
||||||
|
|
||||||
|
setAutoCompleteListShow(false)
|
||||||
|
|
||||||
|
// Pre-calculate the offset after InputCommand
|
||||||
|
const start = atIndex
|
||||||
|
const end = selectionStart + newMessage.length - message.length
|
||||||
|
|
||||||
|
const atUserPosition: [number, number] = [start + (hasBeforeSpace ? 0 : +1), end - 1 + (hasAfterSpace ? 0 : -1)]
|
||||||
|
|
||||||
|
// Calculate the difference after replacing @text with @user
|
||||||
|
const offset = newMessage.length - message.length - (atUserPosition[1] - atUserPosition[0])
|
||||||
|
|
||||||
|
updateAtUserAtRecord(newMessage, ...atUserPosition, offset, selectedUser.userId)
|
||||||
|
|
||||||
|
send(messageInputDomain.command.InputCommand(newMessage))
|
||||||
|
requestIdleCallback(() => {
|
||||||
|
inputRef.current!.setSelectionRange(end, end)
|
||||||
|
inputRef.current!.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = document.querySelector(__NAME__)?.shadowRoot
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative z-10 grid gap-y-2 px-4 pb-4 pt-2 before:pointer-events-none before:absolute before:inset-x-4 before:-top-2 before:h-2 before:bg-gradient-to-t before:from-slate-50 before:from-30% before:to-transparent">
|
<div className="relative z-10 grid gap-y-2 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">
|
||||||
|
<Presence present={autoCompleteListShow}>
|
||||||
|
<Portal
|
||||||
|
container={root}
|
||||||
|
ref={shareAutoCompleteListRef}
|
||||||
|
className="fixed z-infinity w-36 -translate-y-full 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 - 212px)`, top: `${y}px` }}
|
||||||
|
>
|
||||||
|
<ScrollArea className="max-h-[204px] min-h-9 p-1" ref={setScrollParentRef}>
|
||||||
|
<Virtuoso
|
||||||
|
ref={virtuosoRef}
|
||||||
|
data={autoCompleteList}
|
||||||
|
defaultItemHeight={28}
|
||||||
|
context={{ currentItemIndex: selectedUserIndex }}
|
||||||
|
customScrollParent={scrollParentRef!}
|
||||||
|
itemContent={(index, user) => (
|
||||||
|
<div
|
||||||
|
key={user.userId}
|
||||||
|
onClick={() => handleInjectAtSyntax(user.username)}
|
||||||
|
onMouseEnter={() => setSelectedUserIndex(index)}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-pointer select-none items-center gap-x-2 rounded-md px-2 py-1.5 outline-none',
|
||||||
|
{
|
||||||
|
'bg-accent text-accent-foreground': index === selectedUserIndex
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Avatar className="size-4 shrink-0">
|
||||||
|
<AvatarImage className="size-full" src={user.userAvatar} alt="avatar" />
|
||||||
|
<AvatarFallback>{user.username.at(0)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 truncate text-xs text-slate-500">{user.username}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
></Virtuoso>
|
||||||
|
</ScrollArea>
|
||||||
|
</Portal>
|
||||||
|
</Presence>
|
||||||
<MessageInput
|
<MessageInput
|
||||||
ref={inputRef}
|
ref={shareRef}
|
||||||
value={message}
|
value={message}
|
||||||
onEnter={handleSend}
|
|
||||||
onInput={handleInput}
|
onInput={handleInput}
|
||||||
onCompositionEnd={() => (isComposing.current = false)}
|
onKeyDown={handleKeyDown}
|
||||||
onCompositionStart={() => (isComposing.current = true)}
|
|
||||||
maxLength={MESSAGE_MAX_LENGTH}
|
maxLength={MESSAGE_MAX_LENGTH}
|
||||||
></MessageInput>
|
></MessageInput>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<EmojiButton onSelect={handleEmojiSelect}></EmojiButton>
|
<EmojiButton onSelect={handleInjectEmoji}></EmojiButton>
|
||||||
{/* <Button variant="ghost" size="icon">
|
|
||||||
<ImageIcon size={20} />
|
|
||||||
</Button> */}
|
|
||||||
<Button className="ml-auto" size="sm" onClick={handleSend}>
|
<Button className="ml-auto" size="sm" onClick={handleSend}>
|
||||||
<span className="mr-2">Send</span>
|
<span className="mr-2">Send</span>
|
||||||
<CornerDownLeftIcon className="text-slate-400" size={12}></CornerDownLeftIcon>
|
<CornerDownLeftIcon className="text-slate-400" size={12}></CornerDownLeftIcon>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { type FC } from 'react'
|
import { useState, type FC } from 'react'
|
||||||
import { Globe2Icon } from 'lucide-react'
|
import { Globe2Icon } from 'lucide-react'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar'
|
||||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/HoverCard'
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/HoverCard'
|
||||||
|
@ -7,6 +7,7 @@ import { cn, getSiteInfo } from '@/utils'
|
||||||
import { useRemeshDomain, useRemeshQuery } from 'remesh-react'
|
import { useRemeshDomain, useRemeshQuery } from 'remesh-react'
|
||||||
import RoomDomain from '@/domain/Room'
|
import RoomDomain from '@/domain/Room'
|
||||||
import { ScrollArea } from '@/components/ui/ScrollArea'
|
import { ScrollArea } from '@/components/ui/ScrollArea'
|
||||||
|
import { Virtuoso } from 'react-virtuoso'
|
||||||
|
|
||||||
const Header: FC = () => {
|
const Header: FC = () => {
|
||||||
const siteInfo = getSiteInfo()
|
const siteInfo = getSiteInfo()
|
||||||
|
@ -14,6 +15,8 @@ const Header: FC = () => {
|
||||||
const userList = useRemeshQuery(roomDomain.query.UserListQuery())
|
const userList = useRemeshQuery(roomDomain.query.UserListQuery())
|
||||||
const onlineCount = userList.length
|
const onlineCount = userList.length
|
||||||
|
|
||||||
|
const [scrollParentRef, setScrollParentRef] = useState<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="z-10 grid h-12 grid-flow-col grid-cols-[theme('spacing.20')_auto_theme('spacing.20')] items-center justify-between rounded-t-xl bg-white px-4 backdrop-blur-lg">
|
<div className="z-10 grid h-12 grid-flow-col grid-cols-[theme('spacing.20')_auto_theme('spacing.20')] items-center justify-between rounded-t-xl bg-white px-4 backdrop-blur-lg">
|
||||||
<Avatar className="size-8">
|
<Avatar className="size-8">
|
||||||
|
@ -69,17 +72,22 @@ const Header: FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</HoverCardTrigger>
|
</HoverCardTrigger>
|
||||||
<HoverCardContent className="w-44 rounded-lg px-0 py-2">
|
<HoverCardContent className="w-36 rounded-lg p-0">
|
||||||
<ScrollArea className="max-h-80">
|
<ScrollArea className="max-h-[204px] min-h-9 p-1" ref={setScrollParentRef}>
|
||||||
{userList.map((user) => (
|
<Virtuoso
|
||||||
<div className="flex items-center gap-x-2 px-4 py-2 [content-visibility:auto]" key={user.userId}>
|
data={userList}
|
||||||
<Avatar className="size-6 shrink-0">
|
defaultItemHeight={28}
|
||||||
<AvatarImage src={user.userAvatar} alt="avatar" />
|
customScrollParent={scrollParentRef!}
|
||||||
|
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>
|
<AvatarFallback>{user.username.at(0)}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex-1 truncate text-sm text-slate-500">{user.username}</div>
|
<div className="flex-1 truncate text-xs text-slate-500">{user.username}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
></Virtuoso>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</HoverCardContent>
|
</HoverCardContent>
|
||||||
</HoverCard>
|
</HoverCard>
|
||||||
|
|
|
@ -43,7 +43,8 @@ const generateUserInfo = async (): Promise<UserInfo> => {
|
||||||
createTime: Date.now(),
|
createTime: Date.now(),
|
||||||
themeMode: checkSystemDarkMode() ? 'dark' : 'system',
|
themeMode: checkSystemDarkMode() ? 'dark' : 'system',
|
||||||
danmakuEnabled: true,
|
danmakuEnabled: true,
|
||||||
notificationEnabled: false
|
notificationEnabled: true,
|
||||||
|
notificationType: 'all'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -58,7 +59,8 @@ const generateMessage = async (userInfo: UserInfo): Promise<Message> => {
|
||||||
username,
|
username,
|
||||||
userAvatar,
|
userAvatar,
|
||||||
likeUsers: mockTextList.length ? [] : [{ userId, username, userAvatar }],
|
likeUsers: mockTextList.length ? [] : [{ userId, username, userAvatar }],
|
||||||
hateUsers: []
|
hateUsers: [],
|
||||||
|
atUsers: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,14 +9,14 @@ import { Button } from '@/components/ui/Button'
|
||||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/Form'
|
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/Form'
|
||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
import UserInfoDomain, { type UserInfo } from '@/domain/UserInfo'
|
import UserInfoDomain, { type UserInfo } from '@/domain/UserInfo'
|
||||||
import { checkSystemDarkMode, generateRandomAvatar } from '@/utils'
|
import { checkSystemDarkMode, cn, generateRandomAvatar } from '@/utils'
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup'
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup'
|
||||||
import { Label } from '@/components/ui/Label'
|
import { Label } from '@/components/ui/Label'
|
||||||
import { RefreshCcwIcon } from 'lucide-react'
|
import { RefreshCcwIcon } from 'lucide-react'
|
||||||
import { MAX_AVATAR_SIZE } from '@/constants/config'
|
import { MAX_AVATAR_SIZE } from '@/constants/config'
|
||||||
import { ToastImpl } from '@/domain/impls/Toast'
|
import { ToastImpl } from '@/domain/impls/Toast'
|
||||||
import BlurFade from '@/components/magicui/BlurFade'
|
import BlurFade from '@/components/magicui/BlurFade'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/Checkbox'
|
||||||
import Link from '@/components/Link'
|
import Link from '@/components/Link'
|
||||||
|
|
||||||
const defaultUserInfo: UserInfo = {
|
const defaultUserInfo: UserInfo = {
|
||||||
|
@ -26,7 +26,8 @@ const defaultUserInfo: UserInfo = {
|
||||||
createTime: Date.now(),
|
createTime: Date.now(),
|
||||||
themeMode: checkSystemDarkMode() ? 'dark' : 'system',
|
themeMode: checkSystemDarkMode() ? 'dark' : 'system',
|
||||||
danmakuEnabled: true,
|
danmakuEnabled: true,
|
||||||
notificationEnabled: false
|
notificationEnabled: true,
|
||||||
|
notificationType: 'all'
|
||||||
}
|
}
|
||||||
|
|
||||||
const formSchema = v.object({
|
const formSchema = v.object({
|
||||||
|
@ -34,13 +35,9 @@ const formSchema = v.object({
|
||||||
createTime: v.number(),
|
createTime: v.number(),
|
||||||
// Pure numeric strings will be converted to number
|
// Pure numeric strings will be converted to number
|
||||||
// Issues: https://github.com/unjs/unstorage/issues/277
|
// Issues: https://github.com/unjs/unstorage/issues/277
|
||||||
// name: v.string([
|
|
||||||
// // toTrimmed(),
|
|
||||||
// v.minBytes(1, 'Please enter your username.'),
|
|
||||||
// v.maxBytes(20, 'Your username cannot exceed 20 bytes.')
|
|
||||||
// ]),
|
|
||||||
name: v.pipe(
|
name: v.pipe(
|
||||||
v.string(),
|
v.string(),
|
||||||
|
v.trim(),
|
||||||
v.minBytes(1, 'Please enter your username.'),
|
v.minBytes(1, 'Please enter your username.'),
|
||||||
v.maxBytes(20, 'Your username cannot exceed 20 bytes.')
|
v.maxBytes(20, 'Your username cannot exceed 20 bytes.')
|
||||||
),
|
),
|
||||||
|
@ -54,7 +51,8 @@ const formSchema = v.object({
|
||||||
v.union([v.literal('system'), v.literal('light'), v.literal('dark')], 'Please select extension theme mode.')
|
v.union([v.literal('system'), v.literal('light'), v.literal('dark')], 'Please select extension theme mode.')
|
||||||
),
|
),
|
||||||
danmakuEnabled: v.boolean(),
|
danmakuEnabled: v.boolean(),
|
||||||
notificationEnabled: v.boolean()
|
notificationEnabled: v.boolean(),
|
||||||
|
notificationType: v.pipe(v.string(), v.union([v.literal('all'), v.literal('at')], 'Please select notification type.'))
|
||||||
})
|
})
|
||||||
|
|
||||||
const ProfileForm = () => {
|
const ProfileForm = () => {
|
||||||
|
@ -136,7 +134,7 @@ const ProfileForm = () => {
|
||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Username</FormLabel>
|
<FormLabel className="font-semibold">Username</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="Please enter your username" {...field} />
|
<Input placeholder="Please enter your username" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
@ -150,7 +148,6 @@ const ProfileForm = () => {
|
||||||
name="danmakuEnabled"
|
name="danmakuEnabled"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
{/* <FormLabel>Username</FormLabel> */}
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
@ -159,7 +156,7 @@ const ProfileForm = () => {
|
||||||
onCheckedChange={field.onChange}
|
onCheckedChange={field.onChange}
|
||||||
checked={field.value}
|
checked={field.value}
|
||||||
/>
|
/>
|
||||||
<FormLabel className="cursor-pointer" htmlFor="enable-danmaku">
|
<FormLabel className="cursor-pointer font-semibold" htmlFor="enable-danmaku">
|
||||||
Enable Danmaku
|
Enable Danmaku
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
</div>
|
</div>
|
||||||
|
@ -174,25 +171,67 @@ const ProfileForm = () => {
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="notificationType"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="notificationEnabled"
|
name="notificationEnabled"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
{/* <FormLabel>Username</FormLabel> */}
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
defaultChecked={false}
|
defaultChecked={false}
|
||||||
id="notification-enabled"
|
id="enable-notification"
|
||||||
onCheckedChange={field.onChange}
|
onCheckedChange={field.onChange}
|
||||||
checked={field.value}
|
checked={field.value}
|
||||||
/>
|
/>
|
||||||
<FormLabel className="cursor-pointer" htmlFor="notification-enabled">
|
<FormLabel className="cursor-pointer font-semibold" htmlFor="enable-notification">
|
||||||
Enable Notification
|
Enable Notification
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
</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>
|
<FormDescription>Enabling this option will display desktop notifications for messages.</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
@ -203,20 +242,26 @@ const ProfileForm = () => {
|
||||||
name="themeMode"
|
name="themeMode"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Theme Mode</FormLabel>
|
<FormLabel className="font-semibold">Theme Mode</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<RadioGroup className="flex gap-x-4" onValueChange={field.onChange} value={field.value}>
|
<RadioGroup className="flex gap-x-4" onValueChange={field.onChange} value={field.value}>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="system" id="r1" />
|
<RadioGroupItem value="system" id="system" />
|
||||||
<Label htmlFor="r1">System</Label>
|
<Label className="cursor-pointer" htmlFor="system">
|
||||||
|
System
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="light" id="r2" />
|
<RadioGroupItem value="light" id="light" />
|
||||||
<Label htmlFor="r2">Light</Label>
|
<Label className="cursor-pointer" htmlFor="light">
|
||||||
|
Light
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="dark" id="r3" />
|
<RadioGroupItem value="dark" id="dark" />
|
||||||
<Label htmlFor="r3">Dark</Label>
|
<Label className="cursor-pointer" htmlFor="dark">
|
||||||
|
Dark
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
|
@ -673,12 +673,15 @@ section:has([data-sonner-toaster]) {
|
||||||
|
|
||||||
/* Custom styles */
|
/* Custom styles */
|
||||||
:where([data-sonner-toaster]) {
|
:where([data-sonner-toaster]) {
|
||||||
width: 200px;
|
max-width: 300px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
:where([data-sonner-toast][data-styled='true']) {
|
:where([data-sonner-toast][data-styled='true']) {
|
||||||
width: 200px;
|
max-width: 300px;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
|
@ -81,11 +81,4 @@
|
||||||
all: initial !important;
|
all: initial !important;
|
||||||
direction: ltr !important;
|
direction: ltr !important;
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* Fix: scroll area dispay: table
|
|
||||||
* @see https://github.com/radix-ui/primitives/issues/3129
|
|
||||||
*/
|
|
||||||
[data-radix-scroll-area-viewport] > div {
|
|
||||||
display: block !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,9 +10,10 @@ const PopoverContent = React.forwardRef<
|
||||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => {
|
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => {
|
||||||
const shadowRoot = document.querySelector(__NAME__)!.shadowRoot! as unknown as HTMLElement
|
const root = document.querySelector(__NAME__)?.shadowRoot
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PopoverPrimitive.Portal container={shadowRoot}>
|
<PopoverPrimitive.Portal container={root}>
|
||||||
<PopoverPrimitive.Content
|
<PopoverPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
align={align}
|
align={align}
|
||||||
|
|
|
@ -8,7 +8,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ classNa
|
||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex min-h-[60px] w-full rounded-md border border-input text-primary bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
'flex min-h-[60px] w-full rounded-md border border-input text-primary bg-transparent p-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import * as React from "react"
|
import * as React from 'react'
|
||||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
|
||||||
import { CheckIcon } from "@radix-ui/react-icons"
|
import { CheckIcon } from '@radix-ui/react-icons'
|
||||||
|
|
||||||
import { cn } from "@/utils/index"
|
import { cn } from '@/utils/index'
|
||||||
|
|
||||||
const Checkbox = React.forwardRef<
|
const Checkbox = React.forwardRef<
|
||||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||||
|
@ -11,14 +11,12 @@ const Checkbox = React.forwardRef<
|
||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
'peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<CheckboxPrimitive.Indicator
|
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
|
||||||
className={cn("flex items-center justify-center text-current")}
|
|
||||||
>
|
|
||||||
<CheckIcon className="h-4 w-4" />
|
<CheckIcon className="h-4 w-4" />
|
||||||
</CheckboxPrimitive.Indicator>
|
</CheckboxPrimitive.Indicator>
|
||||||
</CheckboxPrimitive.Root>
|
</CheckboxPrimitive.Root>
|
||||||
|
|
|
@ -16,6 +16,10 @@ export interface MessageUser {
|
||||||
userAvatar: string
|
userAvatar: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AtUser extends MessageUser {
|
||||||
|
positions: [number, number][]
|
||||||
|
}
|
||||||
|
|
||||||
export interface NormalMessage extends MessageUser {
|
export interface NormalMessage extends MessageUser {
|
||||||
type: MessageType.Normal
|
type: MessageType.Normal
|
||||||
id: string
|
id: string
|
||||||
|
@ -23,6 +27,7 @@ export interface NormalMessage extends MessageUser {
|
||||||
date: number
|
date: number
|
||||||
likeUsers: MessageUser[]
|
likeUsers: MessageUser[]
|
||||||
hateUsers: MessageUser[]
|
hateUsers: MessageUser[]
|
||||||
|
atUsers: AtUser[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromptMessage extends MessageUser {
|
export interface PromptMessage extends MessageUser {
|
||||||
|
|
|
@ -69,11 +69,22 @@ const NotificationDomain = Remesh.domain({
|
||||||
name: 'Notification.OnRoomMessageEffect',
|
name: 'Notification.OnRoomMessageEffect',
|
||||||
impl: ({ fromEvent, get }) => {
|
impl: ({ fromEvent, get }) => {
|
||||||
const onTextMessage$ = fromEvent(roomDomain.event.OnTextMessageEvent)
|
const onTextMessage$ = fromEvent(roomDomain.event.OnTextMessageEvent)
|
||||||
|
|
||||||
const onMessage$ = merge(onTextMessage$).pipe(
|
const onMessage$ = merge(onTextMessage$).pipe(
|
||||||
map((message) => {
|
map((message) => {
|
||||||
const notificationEnabled = get(IsEnabledQuery())
|
const notificationEnabled = get(IsEnabledQuery())
|
||||||
return notificationEnabled ? PushCommand(message) : null
|
if (notificationEnabled) {
|
||||||
|
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 null
|
||||||
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Remesh } from 'remesh'
|
import { Remesh } from 'remesh'
|
||||||
import { map, merge, of, EMPTY, mergeMap, fromEvent, fromEventPattern } from 'rxjs'
|
import { map, merge, of, EMPTY, mergeMap, fromEvent, fromEventPattern } from 'rxjs'
|
||||||
import { NormalMessage, type MessageUser } from './MessageList'
|
import { AtUser, NormalMessage, type MessageUser } from './MessageList'
|
||||||
import { PeerRoomExtern } from '@/domain/externs/PeerRoom'
|
import { PeerRoomExtern } from '@/domain/externs/PeerRoom'
|
||||||
import MessageListDomain, { MessageType } from '@/domain/MessageList'
|
import MessageListDomain, { MessageType } from '@/domain/MessageList'
|
||||||
import UserInfoDomain from '@/domain/UserInfo'
|
import UserInfoDomain from '@/domain/UserInfo'
|
||||||
|
@ -38,6 +38,7 @@ export interface TextMessage extends MessageUser {
|
||||||
type: SendType.Text
|
type: SendType.Text
|
||||||
id: string
|
id: string
|
||||||
body: string
|
body: string
|
||||||
|
atUsers: AtUser[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RoomMessage = SyncUserMessage | LikeMessage | HateMessage | TextMessage
|
export type RoomMessage = SyncUserMessage | LikeMessage | HateMessage | TextMessage
|
||||||
|
@ -134,16 +135,17 @@ const RoomDomain = Remesh.domain({
|
||||||
|
|
||||||
const SendTextMessageCommand = domain.command({
|
const SendTextMessageCommand = domain.command({
|
||||||
name: 'Room.SendTextMessageCommand',
|
name: 'Room.SendTextMessageCommand',
|
||||||
impl: ({ get }, message: string) => {
|
impl: ({ get }, message: string | { body: string; atUsers: AtUser[] }) => {
|
||||||
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
|
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||||
|
|
||||||
const textMessage: TextMessage = {
|
const textMessage: TextMessage = {
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
type: SendType.Text,
|
type: SendType.Text,
|
||||||
body: message,
|
body: typeof message === 'string' ? message : message.body,
|
||||||
userId,
|
userId,
|
||||||
username,
|
username,
|
||||||
userAvatar
|
userAvatar,
|
||||||
|
atUsers: typeof message === 'string' ? [] : message.atUsers
|
||||||
}
|
}
|
||||||
|
|
||||||
const listMessage: NormalMessage = {
|
const listMessage: NormalMessage = {
|
||||||
|
@ -151,7 +153,8 @@ const RoomDomain = Remesh.domain({
|
||||||
type: MessageType.Normal,
|
type: MessageType.Normal,
|
||||||
date: Date.now(),
|
date: Date.now(),
|
||||||
likeUsers: [],
|
likeUsers: [],
|
||||||
hateUsers: []
|
hateUsers: [],
|
||||||
|
atUsers: typeof message === 'string' ? [] : message.atUsers
|
||||||
}
|
}
|
||||||
|
|
||||||
peerRoom.sendMessage(textMessage)
|
peerRoom.sendMessage(textMessage)
|
||||||
|
|
|
@ -12,6 +12,7 @@ export interface UserInfo {
|
||||||
themeMode: 'system' | 'light' | 'dark'
|
themeMode: 'system' | 'light' | 'dark'
|
||||||
danmakuEnabled: boolean
|
danmakuEnabled: boolean
|
||||||
notificationEnabled: boolean
|
notificationEnabled: boolean
|
||||||
|
notificationType: 'all' | 'at'
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserInfoDomain = Remesh.domain({
|
const UserInfoDomain = Remesh.domain({
|
||||||
|
|
|
@ -1,51 +0,0 @@
|
||||||
import { type RefObject, useEffect, useRef } from 'react'
|
|
||||||
|
|
||||||
export type Events = Array<keyof GlobalEventHandlersEventMap>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Waiting for PR merge
|
|
||||||
* @see https://github.com/streamich/react-use/pull/2528
|
|
||||||
*/
|
|
||||||
const useClickAway = <E extends Event = Event>(
|
|
||||||
ref: RefObject<HTMLElement | null>,
|
|
||||||
onClickAway: (event: E) => void,
|
|
||||||
events: Events = ['mousedown', 'touchstart']
|
|
||||||
) => {
|
|
||||||
const savedCallback = useRef(onClickAway)
|
|
||||||
useEffect(() => {
|
|
||||||
savedCallback.current = onClickAway
|
|
||||||
}, [onClickAway])
|
|
||||||
useEffect(() => {
|
|
||||||
const { current: el } = ref
|
|
||||||
if (!el) return
|
|
||||||
|
|
||||||
const rootNode = el.getRootNode()
|
|
||||||
const isInShadow = rootNode instanceof ShadowRoot
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When events are captured outside the component, events that occur in shadow DOM will target the host element
|
|
||||||
* so additional event listeners need to be added for shadowDom
|
|
||||||
*
|
|
||||||
* document shadowDom target
|
|
||||||
* | | |
|
|
||||||
* |- on(document) -|- on(shadowRoot) -|
|
|
||||||
*/
|
|
||||||
const handler = (event: SafeAny) => {
|
|
||||||
!el.contains(event.target) && event.target.shadowRoot !== rootNode && savedCallback.current(event)
|
|
||||||
}
|
|
||||||
for (const eventName of events) {
|
|
||||||
// eslint-disable-next-line @eslint-react/web-api/no-leaked-event-listener
|
|
||||||
document.addEventListener(eventName, handler)
|
|
||||||
// eslint-disable-next-line @eslint-react/web-api/no-leaked-event-listener
|
|
||||||
isInShadow && rootNode.addEventListener(eventName, handler)
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
for (const eventName of events) {
|
|
||||||
document.removeEventListener(eventName, handler)
|
|
||||||
isInShadow && rootNode.removeEventListener(eventName, handler)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [events, ref])
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useClickAway
|
|
41
src/hooks/useCursorPosition.ts
Normal file
41
src/hooks/useCursorPosition.ts
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import { RefCallback, useCallback, useRef, useState } from 'react'
|
||||||
|
import getCursorPosition, { Position } from '@/utils/getCursorPosition'
|
||||||
|
|
||||||
|
const useCursorPosition = () => {
|
||||||
|
const [position, setPosition] = useState<Position>({ x: 0, y: 0, selectionStart: 0, selectionEnd: 0 })
|
||||||
|
|
||||||
|
const handler = async (e: Event) => {
|
||||||
|
const newPosition = await getCursorPosition(e.target as HTMLInputElement | HTMLTextAreaElement)
|
||||||
|
if (JSON.stringify(newPosition) !== JSON.stringify(position)) {
|
||||||
|
setPosition(newPosition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null)
|
||||||
|
|
||||||
|
const setRef: RefCallback<HTMLInputElement | HTMLTextAreaElement | null> = useCallback(
|
||||||
|
(node) => {
|
||||||
|
if (handleRef.current) {
|
||||||
|
handleRef.current.removeEventListener('click', handler)
|
||||||
|
handleRef.current.removeEventListener('input', handler)
|
||||||
|
handleRef.current.removeEventListener('keydown', handler)
|
||||||
|
handleRef.current.removeEventListener('keyup', handler)
|
||||||
|
}
|
||||||
|
if (node) {
|
||||||
|
node.addEventListener('click', handler)
|
||||||
|
node.addEventListener('input', handler)
|
||||||
|
node.addEventListener('keydown', handler)
|
||||||
|
node.addEventListener('keyup', handler)
|
||||||
|
}
|
||||||
|
handleRef.current = node
|
||||||
|
},
|
||||||
|
[handler]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...position,
|
||||||
|
setRef
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useCursorPosition
|
|
@ -18,7 +18,10 @@ const useDarg = (options: DargOptions) => {
|
||||||
const [position, setPosition] = useState({ x: clamp(initX, minX, maxX), y: clamp(initY, minY, maxY) })
|
const [position, setPosition] = useState({ x: clamp(initX, minX, maxX), y: clamp(initY, minY, maxY) })
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
setPosition({ x: clamp(initX, minX, maxX), y: clamp(initY, minY, maxY) })
|
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])
|
}, [initX, initY, maxX, minX, maxY, minY])
|
||||||
|
|
||||||
const isMove = useRef(false)
|
const isMove = useRef(false)
|
||||||
|
@ -68,7 +71,7 @@ const useDarg = (options: DargOptions) => {
|
||||||
|
|
||||||
const handleRef = useRef<HTMLElement | null>(null)
|
const handleRef = useRef<HTMLElement | null>(null)
|
||||||
|
|
||||||
const setHandleRef = useCallback(
|
const setRef = useCallback(
|
||||||
(node: HTMLElement | null) => {
|
(node: HTMLElement | null) => {
|
||||||
if (handleRef.current) {
|
if (handleRef.current) {
|
||||||
handleRef.current.removeEventListener('mousedown', handleStart)
|
handleRef.current.removeEventListener('mousedown', handleStart)
|
||||||
|
@ -85,7 +88,7 @@ const useDarg = (options: DargOptions) => {
|
||||||
[handleEnd, handleMove, handleStart]
|
[handleEnd, handleMove, handleStart]
|
||||||
)
|
)
|
||||||
|
|
||||||
return { ref: setHandleRef, ...position }
|
return { setRef, ...position }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useDarg
|
export default useDarg
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
|
import { RefCallback, useCallback, useLayoutEffect, useRef, useState } from 'react'
|
||||||
import { clamp, isInRange } from '@/utils'
|
import { clamp, isInRange } from '@/utils'
|
||||||
|
|
||||||
export interface ResizableOptions {
|
export interface ResizableOptions {
|
||||||
|
@ -14,8 +14,11 @@ const useResizable = (options: ResizableOptions) => {
|
||||||
const [size, setSize] = useState(clamp(initSize, minSize, maxSize))
|
const [size, setSize] = useState(clamp(initSize, minSize, maxSize))
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
setSize(clamp(initSize, minSize, maxSize))
|
const newSize = clamp(initSize, minSize, maxSize)
|
||||||
}, [initSize, minSize, maxSize])
|
if (newSize !== size) {
|
||||||
|
setSize(newSize)
|
||||||
|
}
|
||||||
|
}, [initSize, minSize, maxSize, size])
|
||||||
|
|
||||||
const position = useRef(0)
|
const position = useRef(0)
|
||||||
|
|
||||||
|
@ -74,8 +77,8 @@ const useResizable = (options: ResizableOptions) => {
|
||||||
const handlerRef = useRef<HTMLElement | null>(null)
|
const handlerRef = useRef<HTMLElement | null>(null)
|
||||||
|
|
||||||
// Watch ref: https://medium.com/@teh_builder/ref-objects-inside-useeffect-hooks-eb7c15198780
|
// Watch ref: https://medium.com/@teh_builder/ref-objects-inside-useeffect-hooks-eb7c15198780
|
||||||
const setHandleRef = useCallback(
|
const setRef: RefCallback<HTMLElement | null> = useCallback(
|
||||||
(node: HTMLElement | null) => {
|
(node) => {
|
||||||
if (handlerRef.current) {
|
if (handlerRef.current) {
|
||||||
handlerRef.current.removeEventListener('mousedown', handleStart)
|
handlerRef.current.removeEventListener('mousedown', handleStart)
|
||||||
document.removeEventListener('mouseup', handleEnd)
|
document.removeEventListener('mouseup', handleEnd)
|
||||||
|
@ -91,7 +94,7 @@ const useResizable = (options: ResizableOptions) => {
|
||||||
[handleEnd, handleMove, handleStart]
|
[handleEnd, handleMove, handleStart]
|
||||||
)
|
)
|
||||||
|
|
||||||
return { size, ref: setHandleRef }
|
return { size, setRef }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useResizable
|
export default useResizable
|
||||||
|
|
21
src/hooks/useShareRef.ts
Normal file
21
src/hooks/useShareRef.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import { ForwardedRef, MutableRefObject, RefCallback, useCallback } from 'react'
|
||||||
|
|
||||||
|
const useShareRef = <T extends HTMLElement | null>(
|
||||||
|
...refs: (MutableRefObject<T> | ForwardedRef<T> | RefCallback<T>)[]
|
||||||
|
) => {
|
||||||
|
const setRef = useCallback(
|
||||||
|
(node: T) =>
|
||||||
|
refs.forEach((ref) => {
|
||||||
|
if (typeof ref === 'function') {
|
||||||
|
ref(node)
|
||||||
|
} else if (ref) {
|
||||||
|
ref.current = node
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[...refs]
|
||||||
|
)
|
||||||
|
|
||||||
|
return setRef
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useShareRef
|
52
src/hooks/useTriggerAway.ts
Normal file
52
src/hooks/useTriggerAway.ts
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import { RefCallback, useCallback, useRef } from 'react'
|
||||||
|
|
||||||
|
export type Events = Array<keyof GlobalEventHandlersEventMap>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see https://github.com/streamich/react-use/pull/2528
|
||||||
|
*/
|
||||||
|
const useTriggerAway = <E extends Event = Event>(events: Events, callback: (event: E) => void) => {
|
||||||
|
const handleRef = useRef<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const handler = (event: SafeAny) => {
|
||||||
|
const rootNode = handleRef.current?.getRootNode()
|
||||||
|
!handleRef.current?.contains(event.target) && event.target.shadowRoot !== rootNode && callback(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When events are captured outside the component, events that occur in shadow DOM will target the host element
|
||||||
|
* so additional event listeners need to be added for shadowDom
|
||||||
|
*
|
||||||
|
* document shadowDom target
|
||||||
|
* | | |
|
||||||
|
* |- on(document) -|- on(shadowRoot) -|
|
||||||
|
*/
|
||||||
|
const setRef: RefCallback<HTMLElement | null> = useCallback(
|
||||||
|
(node) => {
|
||||||
|
if (handleRef.current) {
|
||||||
|
const rootNode = handleRef.current.getRootNode()
|
||||||
|
const isInShadow = rootNode instanceof ShadowRoot
|
||||||
|
events.forEach(() => {
|
||||||
|
for (const eventName of events) {
|
||||||
|
document.removeEventListener(eventName, handler)
|
||||||
|
isInShadow && rootNode.removeEventListener(eventName, handler)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (node) {
|
||||||
|
const rootNode = node.getRootNode()
|
||||||
|
const isInShadow = rootNode instanceof ShadowRoot
|
||||||
|
events.forEach((eventName) => {
|
||||||
|
document.addEventListener(eventName, handler)
|
||||||
|
isInShadow && rootNode.addEventListener(eventName, handler)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
handleRef.current = node
|
||||||
|
},
|
||||||
|
[handler]
|
||||||
|
)
|
||||||
|
|
||||||
|
return { setRef }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useTriggerAway
|
74
src/utils/getCursorPosition.ts
Normal file
74
src/utils/getCursorPosition.ts
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
import { createElement } from '@/utils'
|
||||||
|
|
||||||
|
export interface Position {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
selectionStart: number
|
||||||
|
selectionEnd: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCursorPosition = (target: HTMLInputElement | HTMLTextAreaElement) => {
|
||||||
|
return new Promise<Position>((resolve, reject) =>
|
||||||
|
requestIdleCallback(() => {
|
||||||
|
try {
|
||||||
|
const value = target.value
|
||||||
|
|
||||||
|
const inputWrapper = createElement<HTMLDivElement>(
|
||||||
|
`<div style="position: fixed; z-index: calc(-infinity); width: 0; height: 0; overflow: hidden; visibility: hidden; pointer-events: none;"></div>`
|
||||||
|
// `<div id="input-wrapper" style="position: fixed"></div>`
|
||||||
|
)
|
||||||
|
const copyInput = createElement<HTMLDivElement>(`<div contenteditable></div>`)
|
||||||
|
|
||||||
|
inputWrapper.appendChild(copyInput)
|
||||||
|
target.ownerDocument.body.appendChild(inputWrapper)
|
||||||
|
|
||||||
|
const { left, top, width, height } = target.getBoundingClientRect()
|
||||||
|
|
||||||
|
const isEmptyOrBreakEnd = /(\n|\s*$)/.test(value)
|
||||||
|
copyInput.textContent = isEmptyOrBreakEnd ? `${value}\u200b` : value
|
||||||
|
|
||||||
|
const copyStyle = getComputedStyle(target)
|
||||||
|
|
||||||
|
for (const key of copyStyle) {
|
||||||
|
Reflect.set(copyInput.style, key, copyStyle[key as keyof CSSStyleDeclaration])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.tagName === 'INPUT') {
|
||||||
|
copyInput.style.lineHeight = copyStyle.height
|
||||||
|
}
|
||||||
|
|
||||||
|
copyInput.style.overflow = 'auto'
|
||||||
|
|
||||||
|
copyInput.style.width = `${width}px`
|
||||||
|
copyInput.style.height = `${height}px`
|
||||||
|
copyInput.style.boxSizing = 'border-box'
|
||||||
|
copyInput.style.margin = '0'
|
||||||
|
copyInput.style.position = 'fixed'
|
||||||
|
copyInput.style.top = `${top}px`
|
||||||
|
copyInput.style.left = `${left}px`
|
||||||
|
copyInput.style.pointerEvents = 'none'
|
||||||
|
|
||||||
|
// sync scroll
|
||||||
|
copyInput.scrollTop = target.scrollTop
|
||||||
|
copyInput.scrollLeft = target.scrollLeft
|
||||||
|
|
||||||
|
const selectionStart = target.selectionStart!
|
||||||
|
const selectionEnd = target.selectionEnd!
|
||||||
|
|
||||||
|
const range = new Range()
|
||||||
|
range.setStart(copyInput.childNodes[0], selectionStart)
|
||||||
|
range.setEnd(copyInput.childNodes[0], isEmptyOrBreakEnd ? selectionEnd + 1 : selectionEnd)
|
||||||
|
|
||||||
|
const { x, y } = range.getBoundingClientRect()
|
||||||
|
|
||||||
|
target.ownerDocument.body.removeChild(inputWrapper)
|
||||||
|
|
||||||
|
resolve({ x, y, selectionStart, selectionEnd })
|
||||||
|
} catch (error) {
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default getCursorPosition
|
45
src/utils/getTextSimilarity.ts
Normal file
45
src/utils/getTextSimilarity.ts
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
/**
|
||||||
|
* Calculates the length of the Longest Common Subsequence (LCS) between two strings.
|
||||||
|
* @param a - The first string.
|
||||||
|
* @param b - The second string.
|
||||||
|
* @returns The length of the longest common subsequence.
|
||||||
|
* @see https://en.wikipedia.org/wiki/Longest_common_subsequence
|
||||||
|
*/
|
||||||
|
const getTextLCS = (a: string, b: string): number => {
|
||||||
|
// Create a 2D array to store the lengths of longest common subsequences
|
||||||
|
const dp: number[][] = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0))
|
||||||
|
|
||||||
|
// Fill the dp array
|
||||||
|
for (let i = 1; i <= a.length; i++) {
|
||||||
|
for (let j = 1; j <= b.length; j++) {
|
||||||
|
// If characters match, increment the length of the LCS found so far
|
||||||
|
if (a[i - 1] === b[j - 1]) {
|
||||||
|
dp[i][j] = dp[i - 1][j - 1] + 1
|
||||||
|
} else {
|
||||||
|
// If characters do not match, take the maximum length from the previous computations
|
||||||
|
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The length of the longest common subsequence is found in the bottom-right cell of the dp array
|
||||||
|
return dp[a.length][b.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the similarity between two strings based on their longest common subsequence.
|
||||||
|
* @param a - The first string.
|
||||||
|
* @param b - The second string.
|
||||||
|
* @returns A number representing the similarity between the two strings (0 to 1).
|
||||||
|
*/
|
||||||
|
const getTextSimilarity = (a: string, b: string): number => {
|
||||||
|
// Get the length of the longest common subsequence
|
||||||
|
const lcsLength: number = getTextLCS(a, b)
|
||||||
|
// Get the maximum length of the two strings
|
||||||
|
const maxLength: number = Math.max(a.length, b.length)
|
||||||
|
|
||||||
|
// Calculate similarity based on the length of the LCS
|
||||||
|
return maxLength === 0 ? 0 : lcsLength / maxLength
|
||||||
|
}
|
||||||
|
|
||||||
|
export default getTextSimilarity
|
|
@ -11,3 +11,5 @@ export { default as throttle } from './throttle'
|
||||||
export { chunk, desert, upsert } from './array'
|
export { chunk, desert, upsert } from './array'
|
||||||
export { default as generateRandomAvatar } from './generateRandomAvatar'
|
export { default as generateRandomAvatar } from './generateRandomAvatar'
|
||||||
export { default as generateRandomName } from './generateRandomName'
|
export { default as generateRandomName } from './generateRandomName'
|
||||||
|
export { default as getCursorPosition } from './getCursorPosition'
|
||||||
|
export { default as getTextSimilarity } from './getTextSimilarity'
|
||||||
|
|
Loading…
Reference in a new issue