feat: support @user syntax

This commit is contained in:
molvqingtai 2024-10-27 09:02:56 +08:00
parent 4eba638a36
commit bef576a77b
31 changed files with 722 additions and 202 deletions

View file

@ -21,7 +21,7 @@ jobs:
- run: pnpm install --ignore-scripts
- run: pnpm wxt prepare
- run: pnpm run lint
- run: pnpm run tsc
- run: pnpm run check
release:
needs: linter

View file

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

View file

@ -1,4 +1,4 @@
import type { Linter } from 'eslint'
// import type { Linter } from 'eslint'
import globals from 'globals'
import pluginJs from '@eslint/js'
import tseslint from 'typescript-eslint'

View file

@ -15,7 +15,7 @@
"pack:firefox": "wxt zip -b firefox",
"lint": "eslint --fix --flag unstable_ts_config",
"clear": "rimraf .output",
"tsc": "tsc --noEmit",
"check": "tsc --noEmit",
"prepare": "husky",
"postinstall": "wxt prepare"
},
@ -54,6 +54,8 @@
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-portal": "^1.1.2",
"@radix-ui/react-presence": "^1.1.1",
"@radix-ui/react-radio-group": "^1.2.1",
"@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-slot": "^1.1.0",

View file

@ -38,6 +38,12 @@ importers:
'@radix-ui/react-popover':
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-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':
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)

View file

@ -1,8 +1,7 @@
import { forwardRef, type ChangeEvent, CompositionEvent, type KeyboardEvent } from 'react'
import { Textarea } from '@/components/ui/Textarea'
import { Markdown } from '@/components/Markdown'
import { cn } from '@/utils'
import { Textarea } from '@/components/ui/Textarea'
import { ScrollArea } from '@/components/ui/ScrollArea'
export interface MessageInputProps {
@ -13,11 +12,16 @@ export interface MessageInputProps {
autoFocus?: boolean
disabled?: boolean
onInput?: (e: ChangeEvent<HTMLTextAreaElement>) => void
onEnter?: (e: KeyboardEvent<HTMLTextAreaElement>) => void
onKeyDown?: (e: KeyboardEvent<HTMLTextAreaElement>) => void
onCompositionStart?: (e: CompositionEvent<HTMLTextAreaElement>) => void
onCompositionEnd?: (e: CompositionEvent<HTMLTextAreaElement>) => void
}
/**
* Need @ syntax highlighting? Waiting for textarea to support Highlight API
*
* @see https://github.com/w3c/csswg-drafts/issues/4603
*/
const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
(
{
@ -25,44 +29,33 @@ const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
className,
maxLength = 500,
onInput,
onEnter,
onKeyDown,
onCompositionStart,
onCompositionEnd,
preview,
autoFocus,
disabled
},
ref
) => {
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !(e.shiftKey || e.ctrlKey || e.altKey || e.metaKey)) {
e.preventDefault()
onEnter?.(e)
}
}
return (
<div className={cn('relative', className)}>
{preview ? (
<Markdown className="max-h-28 rounded-lg border border-input bg-gray-50 2xl:max-h-40">{value}</Markdown>
) : (
<ScrollArea className="box-border max-h-28 w-full rounded-lg border border-input bg-background ring-offset-background focus-within:ring-1 focus-within:ring-ring 2xl:max-h-40">
<Textarea
ref={ref}
onKeyDown={handleKeyDown}
autoFocus={autoFocus}
maxLength={maxLength}
className="box-border resize-none whitespace-pre-wrap break-words border-none bg-gray-50 pb-5 [field-sizing:content] focus:ring-0 focus:ring-offset-0"
rows={2}
value={value}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
placeholder="Type your message here."
onInput={onInput}
disabled={disabled}
/>
</ScrollArea>
)}
<ScrollArea className="box-border max-h-28 w-full rounded-lg border border-input bg-background ring-offset-background focus-within:ring-1 focus-within:ring-ring 2xl:max-h-40">
<Textarea
ref={ref}
onKeyDown={onKeyDown}
autoFocus={autoFocus}
maxLength={maxLength}
className="box-border resize-none whitespace-pre-wrap break-words border-none bg-gray-50 pb-5 [field-sizing:content] [word-break:break-word] focus:ring-0 focus:ring-offset-0"
rows={2}
value={value}
spellCheck={false}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
placeholder="Type your message here."
onInput={onInput}
disabled={disabled}
/>
</ScrollArea>
<div className="absolute bottom-1 right-3 rounded-lg text-xs text-slate-400">
{value?.length ?? 0}/{maxLength}
</div>

View file

@ -25,6 +25,24 @@ const MessageItem: FC<MessageItemProps> = (props) => {
const handleHateChange = (checked: boolean) => {
props.onHateChange?.(checked)
}
let content = props.data.body
// Check if the field exists, compatible with old data
if (props.data.atUsers) {
const atUserPositions = props.data.atUsers.flatMap((user) =>
user.positions.map((position) => ({ username: user.username, userId: user.userId, position }))
)
// Replace from back to front according to position to avoid affecting previous indices
atUserPositions
.sort((a, b) => b.position[0] - a.position[0])
.forEach(({ position, username }) => {
const [start, end] = position
content = `${content.slice(0, start)} **@${username}** ${content.slice(end + 1)}`
})
}
return (
<div
data-index={props.index}
@ -41,7 +59,7 @@ const MessageItem: FC<MessageItemProps> = (props) => {
</div>
<div>
<div className="pb-2">
<Markdown>{props.data.body}</Markdown>
<Markdown>{content}</Markdown>
</div>
<div className="grid grid-flow-col justify-end gap-x-2 leading-none">
<LikeButton

View file

@ -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 { motion, AnimatePresence } from 'framer-motion'
@ -6,7 +6,7 @@ import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
import { Button } from '@/components/ui/Button'
import { EVENT } from '@/constants/event'
import UserInfoDomain from '@/domain/UserInfo'
import useClickAway from '@/hooks/useClickAway'
import useTriggerAway from '@/hooks/useTriggerAway'
import { checkSystemDarkMode, cn } from '@/utils'
import ToastDomain from '@/domain/Toast'
import LogoIcon0 from '@/assets/images/logo-0.svg'
@ -40,11 +40,13 @@ const AppButton: FC = () => {
const [menuOpen, setMenuOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const { width, height } = useWindowSize()
const { x, y, ref } = useDarg({
const {
x,
y,
setRef: appButtonRef
} = useDarg({
initX: appPosition.x,
initY: appPosition.y,
minX: 44,
@ -57,9 +59,7 @@ const AppButton: FC = () => {
appStatusLoadIsFinished && send(appStatusDomain.command.UpdatePositionCommand({ x, y }))
}, [x, y])
useClickAway(menuRef, () => {
setMenuOpen(false)
}, ['click'])
const { setRef: appMenuRef } = useTriggerAway(['click'], () => setMenuOpen(false))
const handleToggleMenu = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
@ -85,7 +85,7 @@ const AppButton: FC = () => {
return (
<div
ref={menuRef}
ref={appMenuRef}
className="fixed bottom-5 right-5 z-infinity grid w-min select-none justify-center gap-y-3"
style={{
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">
<SettingsIcon size={20} />
</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} />
</Button>
</motion.div>

View file

@ -19,7 +19,7 @@ const AppMain: FC<AppMainProps> = ({ children }) => {
const isOnRightSide = x >= width / 2 + 44
const { size, ref } = useResizable({
const { size, setRef } = useResizable({
initSize: Math.max(375, width / 6),
maxSize: Math.max(Math.min(750, width / 3), 375),
minSize: Math.max(375, width / 6),
@ -50,7 +50,7 @@ const AppMain: FC<AppMainProps> = ({ children }) => {
>
{children}
<div
ref={ref}
ref={setRef}
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',
isOnRightSide ? '-left-0.5' : '-right-0.5'

View file

@ -1,4 +1,4 @@
import { ChangeEvent, useRef, type FC } from 'react'
import { ChangeEvent, useMemo, useRef, useState, KeyboardEvent, type FC } from 'react'
import { CornerDownLeftIcon } from 'lucide-react'
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
import MessageInput from '../../components/MessageInput'
@ -7,48 +7,289 @@ import { Button } from '@/components/ui/Button'
import MessageInputDomain from '@/domain/MessageInput'
import { MESSAGE_MAX_LENGTH } from '@/constants/config'
import RoomDomain from '@/domain/Room'
import useCursorPosition from '@/hooks/useCursorPosition'
import useShareRef from '@/hooks/useShareRef'
import { Presence } from '@radix-ui/react-presence'
import { Portal } from '@radix-ui/react-portal'
import useTriggerAway from '@/hooks/useTriggerAway'
import { ScrollArea } from '@/components/ui/ScrollArea'
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'
import UserInfoDomain from '@/domain/UserInfo'
import { cn, getTextSimilarity } from '@/utils'
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
import { AvatarImage } from '@radix-ui/react-avatar'
import ToastDomain from '@/domain/Toast'
const Footer: FC = () => {
const send = useRemeshSend()
const toastDomain = useRemeshDomain(ToastDomain())
const roomDomain = useRemeshDomain(RoomDomain())
const messageInputDomain = useRemeshDomain(MessageInputDomain())
const message = useRemeshQuery(messageInputDomain.query.MessageQuery())
const userInfoDomain = useRemeshDomain(UserInfoDomain())
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
const userList = useRemeshQuery(roomDomain.query.UserListQuery())
const inputRef = useRef<HTMLTextAreaElement>(null)
const isComposing = useRef(false)
const { x, y, selectionStart, selectionEnd, setRef } = useCursorPosition()
const handleInput = (e: ChangeEvent<HTMLTextAreaElement>) => {
send(messageInputDomain.command.InputCommand(e.target.value))
}
const [autoCompleteListShow, setAutoCompleteListShow] = useState(false)
const [scrollParentRef, setScrollParentRef] = useState<HTMLDivElement | null>(null)
const autoCompleteListRef = useRef<HTMLDivElement>(null)
const { setRef: setAutoCompleteListRef } = useTriggerAway(['click'], () => setAutoCompleteListShow(false))
const shareAutoCompleteListRef = useShareRef(setAutoCompleteListRef, autoCompleteListRef)
const isComposing = useRef(false)
const virtuosoRef = useRef<VirtuosoHandle>(null)
const shareRef = useShareRef(inputRef, setRef)
/**
* When inserting a username using the @ syntax, record the username's position information and the mapping relationship between the position information and userId to distinguish between users with the same name.
*/
const atUserRecord = useRef<Map<string, Set<[number, number]>>>(new Map())
const updateAtUserAtRecord = useMemo(
() => (message: string, start: number, end: number, offset: number, atUserId?: string) => {
const positions: [number, number] = [start, end]
// If the editing position is before the end position of @user, update the editing position.
// "@user" => "E@user"
// "@user" => "@useEr"
// "@user" => "@user @user"
atUserRecord.current.forEach((item, userId) => {
const positionList = [...item].map<[number, number]>((item) => {
const inBefore = Math.min(start, end) <= item[1]
return inBefore ? [item[0] + offset + (end - start), item[1] + offset + (end - start)] : item
})
atUserRecord.current.set(userId, new Set(positionList))
})
// Insert a new @user record
if (atUserId) {
atUserRecord.current.set(atUserId, atUserRecord.current.get(atUserId)?.add(positions) ?? new Set([positions]))
}
// After moving, check if the @user in the message matches the saved position record. If not, it means the @user has been edited, so delete that record.
// Filter out records where the stored position does not match the actual position.
atUserRecord.current.forEach((item, userId) => {
// Pre-calculate the offset after InputCommand
const positionList = [...item].filter((item) => {
const username = message.slice(item[0], item[1] + 1)
return username === `@${userList.find((user) => user.userId === userId)?.username}`
})
if (positionList.length) {
atUserRecord.current.set(userId, new Set(positionList))
} else {
atUserRecord.current.delete(userId)
}
})
},
[userList]
)
const [selectedUserIndex, setSelectedUserIndex] = useState(0)
const [searchNameKeyword, setSearchNameKeyword] = useState('')
const autoCompleteList = useMemo(() => {
return userList
.filter((user) => user.userId !== userInfo?.id)
.map((item) => ({
...item,
similarity: getTextSimilarity(searchNameKeyword.toLowerCase(), item.username.toLowerCase())
}))
.toSorted((a, b) => b.similarity - a.similarity)
}, [searchNameKeyword, userList, userInfo])
const selectedUser = autoCompleteList.find((_, index) => index === selectedUserIndex)!
const handleSend = () => {
if (isComposing.current) return
if (!message.trim()) return
send(roomDomain.command.SendTextMessageCommand(message.trim()))
if (!`${message}`.trim()) {
return send(toastDomain.command.WarningCommand('Message cannot be empty.'))
}
const atUsers = [...atUserRecord.current]
.map(([userId, positions]) => {
const user = userList.find((user) => user.userId === userId)
return (user ? { ...user, positions: [...positions] } : undefined)!
})
.filter(Boolean)
send(roomDomain.command.SendTextMessageCommand({ body: message, atUsers }))
send(messageInputDomain.command.ClearCommand())
}
const handleEmojiSelect = (emoji: string) => {
send(messageInputDomain.command.InputCommand(`${message}${emoji}`))
inputRef.current?.focus()
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (autoCompleteListShow && autoCompleteList.length) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
const length = autoCompleteList.length
const prevIndex = selectedUserIndex
if (e.key === 'ArrowDown') {
const index = (prevIndex + 1) % length
setSelectedUserIndex(index)
virtuosoRef.current?.scrollIntoView({ index })
e.preventDefault()
}
if (e.key === 'ArrowUp') {
const index = (prevIndex - 1 + length) % length
setSelectedUserIndex(index)
virtuosoRef.current?.scrollIntoView({ index })
e.preventDefault()
}
}
if (['Escape', 'Backspace', 'Delete', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
if (e.key === 'Delete' || e.key === 'Backspace') {
const isDeleteAt = message.at(selectionStart - 1) === '@'
setAutoCompleteListShow(!isDeleteAt)
} else {
setAutoCompleteListShow(false)
}
setSelectedUserIndex(0)
}
}
if (e.key === 'Enter' && !(e.shiftKey || e.ctrlKey || e.altKey || e.metaKey)) {
if (isComposing.current) return
if (autoCompleteListShow && autoCompleteList.length) {
handleInjectAtSyntax(selectedUser.username)
} else {
handleSend()
}
e.preventDefault()
}
}
const handleInput = (e: ChangeEvent<HTMLTextAreaElement>) => {
const currentMessage = e.target.value
if (autoCompleteListShow) {
const target = e.target as HTMLTextAreaElement
if (target.value) {
const atIndex = target.value.lastIndexOf('@', selectionEnd - 1)
if (atIndex !== -1) {
const keyword = target.value.slice(atIndex + 1, selectionEnd)
setSearchNameKeyword(keyword)
setSelectedUserIndex(0)
virtuosoRef.current?.scrollIntoView({ index: 0 })
}
} else {
setAutoCompleteListShow(false)
}
}
const event = e.nativeEvent as InputEvent
if (event.data === '@' && autoCompleteList.length) {
setAutoCompleteListShow(true)
}
// Pre-calculate the offset after InputCommand
const start = selectionStart
const end = selectionStart + currentMessage.length - message.length
updateAtUserAtRecord(currentMessage, start, end, 0)
send(messageInputDomain.command.InputCommand(currentMessage))
}
const handleInjectEmoji = (emoji: string) => {
const newMessage = `${message.slice(0, selectionEnd)}${emoji}${message.slice(selectionEnd)}`
// Pre-calculate the offset after InputCommand
const start = selectionStart
const end = selectionEnd + newMessage.length - message.length
updateAtUserAtRecord(newMessage, start, end, 0)
send(messageInputDomain.command.InputCommand(newMessage))
requestIdleCallback(() => {
inputRef.current?.setSelectionRange(end, end)
inputRef.current?.focus()
})
}
const handleInjectAtSyntax = (username: string) => {
const atIndex = message.lastIndexOf('@', selectionEnd - 1)
// Determine if there is a space before @
const hasBeforeSpace = message.slice(atIndex - 1, atIndex) === ' '
const hasAfterSpace = message.slice(selectionEnd, selectionEnd + 1) === ' '
const atText = `${hasBeforeSpace ? '' : ' '}@${username}${hasAfterSpace ? '' : ' '}`
const newMessage = message.slice(0, atIndex) + `${atText}` + message.slice(selectionEnd)
setAutoCompleteListShow(false)
// Pre-calculate the offset after InputCommand
const start = atIndex
const end = selectionStart + newMessage.length - message.length
const atUserPosition: [number, number] = [start + (hasBeforeSpace ? 0 : +1), end - 1 + (hasAfterSpace ? 0 : -1)]
// Calculate the difference after replacing @text with @user
const offset = newMessage.length - message.length - (atUserPosition[1] - atUserPosition[0])
updateAtUserAtRecord(newMessage, ...atUserPosition, offset, selectedUser.userId)
send(messageInputDomain.command.InputCommand(newMessage))
requestIdleCallback(() => {
inputRef.current!.setSelectionRange(end, end)
inputRef.current!.focus()
})
}
const root = document.querySelector(__NAME__)?.shadowRoot
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">
<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
ref={inputRef}
ref={shareRef}
value={message}
onEnter={handleSend}
onInput={handleInput}
onCompositionEnd={() => (isComposing.current = false)}
onCompositionStart={() => (isComposing.current = true)}
onKeyDown={handleKeyDown}
maxLength={MESSAGE_MAX_LENGTH}
></MessageInput>
<div className="flex items-center">
<EmojiButton onSelect={handleEmojiSelect}></EmojiButton>
{/* <Button variant="ghost" size="icon">
<ImageIcon size={20} />
</Button> */}
<EmojiButton onSelect={handleInjectEmoji}></EmojiButton>
<Button className="ml-auto" size="sm" onClick={handleSend}>
<span className="mr-2">Send</span>
<CornerDownLeftIcon className="text-slate-400" size={12}></CornerDownLeftIcon>

View file

@ -1,4 +1,4 @@
import { type FC } from 'react'
import { useState, type FC } from 'react'
import { Globe2Icon } from 'lucide-react'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/HoverCard'
@ -7,6 +7,7 @@ import { cn, getSiteInfo } from '@/utils'
import { useRemeshDomain, useRemeshQuery } from 'remesh-react'
import RoomDomain from '@/domain/Room'
import { ScrollArea } from '@/components/ui/ScrollArea'
import { Virtuoso } from 'react-virtuoso'
const Header: FC = () => {
const siteInfo = getSiteInfo()
@ -14,6 +15,8 @@ const Header: FC = () => {
const userList = useRemeshQuery(roomDomain.query.UserListQuery())
const onlineCount = userList.length
const [scrollParentRef, setScrollParentRef] = useState<HTMLDivElement | null>(null)
return (
<div className="z-10 grid h-12 grid-flow-col grid-cols-[theme('spacing.20')_auto_theme('spacing.20')] items-center justify-between rounded-t-xl bg-white px-4 backdrop-blur-lg">
<Avatar className="size-8">
@ -69,17 +72,22 @@ const Header: FC = () => {
</div>
</Button>
</HoverCardTrigger>
<HoverCardContent className="w-44 rounded-lg px-0 py-2">
<ScrollArea className="max-h-80">
{userList.map((user) => (
<div className="flex items-center gap-x-2 px-4 py-2 [content-visibility:auto]" key={user.userId}>
<Avatar className="size-6 shrink-0">
<AvatarImage src={user.userAvatar} alt="avatar" />
<AvatarFallback>{user.username.at(0)}</AvatarFallback>
</Avatar>
<div className="flex-1 truncate text-sm text-slate-500">{user.username}</div>
</div>
))}
<HoverCardContent className="w-36 rounded-lg p-0">
<ScrollArea className="max-h-[204px] min-h-9 p-1" ref={setScrollParentRef}>
<Virtuoso
data={userList}
defaultItemHeight={28}
customScrollParent={scrollParentRef!}
itemContent={(index, user) => (
<div className={cn('flex items-center gap-x-2 rounded-md px-2 py-1.5 outline-none')}>
<Avatar className="size-4 shrink-0">
<AvatarImage className="size-full" src={user.userAvatar} alt="avatar" />
<AvatarFallback>{user.username.at(0)}</AvatarFallback>
</Avatar>
<div className="flex-1 truncate text-xs text-slate-500">{user.username}</div>
</div>
)}
></Virtuoso>
</ScrollArea>
</HoverCardContent>
</HoverCard>

View file

@ -43,7 +43,8 @@ const generateUserInfo = async (): Promise<UserInfo> => {
createTime: Date.now(),
themeMode: checkSystemDarkMode() ? 'dark' : 'system',
danmakuEnabled: true,
notificationEnabled: false
notificationEnabled: true,
notificationType: 'all'
}
}
@ -58,7 +59,8 @@ const generateMessage = async (userInfo: UserInfo): Promise<Message> => {
username,
userAvatar,
likeUsers: mockTextList.length ? [] : [{ userId, username, userAvatar }],
hateUsers: []
hateUsers: [],
atUsers: []
}
}

View file

@ -9,14 +9,14 @@ import { Button } from '@/components/ui/Button'
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/Form'
import { Input } from '@/components/ui/Input'
import UserInfoDomain, { type UserInfo } from '@/domain/UserInfo'
import { checkSystemDarkMode, generateRandomAvatar } from '@/utils'
import { checkSystemDarkMode, cn, generateRandomAvatar } from '@/utils'
import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup'
import { Label } from '@/components/ui/Label'
import { RefreshCcwIcon } from 'lucide-react'
import { MAX_AVATAR_SIZE } from '@/constants/config'
import { ToastImpl } from '@/domain/impls/Toast'
import BlurFade from '@/components/magicui/BlurFade'
import { Checkbox } from '@/components/ui/checkbox'
import { Checkbox } from '@/components/ui/Checkbox'
import Link from '@/components/Link'
const defaultUserInfo: UserInfo = {
@ -26,7 +26,8 @@ const defaultUserInfo: UserInfo = {
createTime: Date.now(),
themeMode: checkSystemDarkMode() ? 'dark' : 'system',
danmakuEnabled: true,
notificationEnabled: false
notificationEnabled: true,
notificationType: 'all'
}
const formSchema = v.object({
@ -34,13 +35,9 @@ const formSchema = v.object({
createTime: v.number(),
// Pure numeric strings will be converted to number
// Issues: https://github.com/unjs/unstorage/issues/277
// name: v.string([
// // toTrimmed(),
// v.minBytes(1, 'Please enter your username.'),
// v.maxBytes(20, 'Your username cannot exceed 20 bytes.')
// ]),
name: v.pipe(
v.string(),
v.trim(),
v.minBytes(1, 'Please enter your username.'),
v.maxBytes(20, 'Your username cannot exceed 20 bytes.')
),
@ -54,7 +51,8 @@ const formSchema = v.object({
v.union([v.literal('system'), v.literal('light'), v.literal('dark')], 'Please select extension theme mode.')
),
danmakuEnabled: v.boolean(),
notificationEnabled: v.boolean()
notificationEnabled: v.boolean(),
notificationType: v.pipe(v.string(), v.union([v.literal('all'), v.literal('at')], 'Please select notification type.'))
})
const ProfileForm = () => {
@ -136,7 +134,7 @@ const ProfileForm = () => {
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormLabel className="font-semibold">Username</FormLabel>
<FormControl>
<Input placeholder="Please enter your username" {...field} />
</FormControl>
@ -150,7 +148,6 @@ const ProfileForm = () => {
name="danmakuEnabled"
render={({ field }) => (
<FormItem>
{/* <FormLabel>Username</FormLabel> */}
<FormControl>
<div className="flex items-center space-x-2">
<Checkbox
@ -159,7 +156,7 @@ const ProfileForm = () => {
onCheckedChange={field.onChange}
checked={field.value}
/>
<FormLabel className="cursor-pointer" htmlFor="enable-danmaku">
<FormLabel className="cursor-pointer font-semibold" htmlFor="enable-danmaku">
Enable Danmaku
</FormLabel>
</div>
@ -174,24 +171,66 @@ const ProfileForm = () => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="notificationEnabled"
name="notificationType"
render={({ field }) => (
<FormItem>
{/* <FormLabel>Username</FormLabel> */}
<FormControl>
<div className="flex items-center space-x-2">
<Checkbox
defaultChecked={false}
id="notification-enabled"
onCheckedChange={field.onChange}
checked={field.value}
/>
<FormLabel className="cursor-pointer" htmlFor="notification-enabled">
Enable Notification
</FormLabel>
</div>
<FormField
control={form.control}
name="notificationEnabled"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center space-x-2">
<Checkbox
defaultChecked={false}
id="enable-notification"
onCheckedChange={field.onChange}
checked={field.value}
/>
<FormLabel className="cursor-pointer font-semibold" htmlFor="enable-notification">
Enable Notification
</FormLabel>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormControl className="pl-6">
<RadioGroup
disabled={!form.getValues('notificationEnabled')}
className="flex gap-x-4"
onValueChange={field.onChange}
value={field.value}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="all" id="all" />
<Label
className={cn(
'cursor-pointer',
!form.getValues('notificationEnabled') && 'cursor-not-allowed opacity-50'
)}
htmlFor="all"
>
All message
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="at" id="at" />
<Label
className={cn(
'cursor-pointer',
!form.getValues('notificationEnabled') && 'cursor-not-allowed opacity-50'
)}
htmlFor="at"
>
Only @self
</Label>
</div>
</RadioGroup>
</FormControl>
<FormDescription>Enabling this option will display desktop notifications for messages.</FormDescription>
<FormMessage />
@ -203,20 +242,26 @@ const ProfileForm = () => {
name="themeMode"
render={({ field }) => (
<FormItem>
<FormLabel>Theme Mode</FormLabel>
<FormLabel className="font-semibold">Theme Mode</FormLabel>
<FormControl>
<RadioGroup className="flex gap-x-4" onValueChange={field.onChange} value={field.value}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="system" id="r1" />
<Label htmlFor="r1">System</Label>
<RadioGroupItem value="system" id="system" />
<Label className="cursor-pointer" htmlFor="system">
System
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="light" id="r2" />
<Label htmlFor="r2">Light</Label>
<RadioGroupItem value="light" id="light" />
<Label className="cursor-pointer" htmlFor="light">
Light
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="dark" id="r3" />
<Label htmlFor="r3">Dark</Label>
<RadioGroupItem value="dark" id="dark" />
<Label className="cursor-pointer" htmlFor="dark">
Dark
</Label>
</div>
</RadioGroup>
</FormControl>

View file

@ -673,12 +673,15 @@ section:has([data-sonner-toaster]) {
/* Custom styles */
:where([data-sonner-toaster]) {
width: 200px;
max-width: 300px;
position: absolute;
display: flex;
justify-content: center;
}
:where([data-sonner-toast][data-styled='true']) {
width: 200px;
max-width: 300px;
padding: 6px 12px;
border-radius: 9999px;
width: fit-content;
}

View file

@ -81,11 +81,4 @@
all: initial !important;
direction: ltr !important;
}
/**
* Fix: scroll area dispay: table
* @see https://github.com/radix-ui/primitives/issues/3129
*/
[data-radix-scroll-area-viewport] > div {
display: block !important;
}
}

View file

@ -10,9 +10,10 @@ const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => {
const shadowRoot = document.querySelector(__NAME__)!.shadowRoot! as unknown as HTMLElement
const root = document.querySelector(__NAME__)?.shadowRoot
return (
<PopoverPrimitive.Portal container={shadowRoot}>
<PopoverPrimitive.Portal container={root}>
<PopoverPrimitive.Content
ref={ref}
align={align}

View file

@ -8,7 +8,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ classNa
return (
<textarea
className={cn(
'flex min-h-[60px] w-full rounded-md border border-input text-primary bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
'flex min-h-[60px] w-full rounded-md border border-input text-primary bg-transparent p-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}

View file

@ -1,8 +1,8 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "@radix-ui/react-icons"
import * as React from 'react'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { CheckIcon } from '@radix-ui/react-icons'
import { cn } from "@/utils/index"
import { cn } from '@/utils/index'
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
@ -11,14 +11,12 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
'peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
<CheckIcon className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>

View file

@ -16,6 +16,10 @@ export interface MessageUser {
userAvatar: string
}
export interface AtUser extends MessageUser {
positions: [number, number][]
}
export interface NormalMessage extends MessageUser {
type: MessageType.Normal
id: string
@ -23,6 +27,7 @@ export interface NormalMessage extends MessageUser {
date: number
likeUsers: MessageUser[]
hateUsers: MessageUser[]
atUsers: AtUser[]
}
export interface PromptMessage extends MessageUser {

View file

@ -69,11 +69,22 @@ const NotificationDomain = Remesh.domain({
name: 'Notification.OnRoomMessageEffect',
impl: ({ fromEvent, get }) => {
const onTextMessage$ = fromEvent(roomDomain.event.OnTextMessageEvent)
const onMessage$ = merge(onTextMessage$).pipe(
map((message) => {
const notificationEnabled = get(IsEnabledQuery())
return notificationEnabled ? PushCommand(message) : null
if (notificationEnabled) {
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
}
})
)

View file

@ -1,6 +1,6 @@
import { Remesh } from 'remesh'
import { map, merge, of, EMPTY, mergeMap, fromEvent, fromEventPattern } from 'rxjs'
import { NormalMessage, type MessageUser } from './MessageList'
import { AtUser, NormalMessage, type MessageUser } from './MessageList'
import { PeerRoomExtern } from '@/domain/externs/PeerRoom'
import MessageListDomain, { MessageType } from '@/domain/MessageList'
import UserInfoDomain from '@/domain/UserInfo'
@ -38,6 +38,7 @@ export interface TextMessage extends MessageUser {
type: SendType.Text
id: string
body: string
atUsers: AtUser[]
}
export type RoomMessage = SyncUserMessage | LikeMessage | HateMessage | TextMessage
@ -134,16 +135,17 @@ const RoomDomain = Remesh.domain({
const SendTextMessageCommand = domain.command({
name: 'Room.SendTextMessageCommand',
impl: ({ get }, message: string) => {
impl: ({ get }, message: string | { body: string; atUsers: AtUser[] }) => {
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
const textMessage: TextMessage = {
id: nanoid(),
type: SendType.Text,
body: message,
body: typeof message === 'string' ? message : message.body,
userId,
username,
userAvatar
userAvatar,
atUsers: typeof message === 'string' ? [] : message.atUsers
}
const listMessage: NormalMessage = {
@ -151,7 +153,8 @@ const RoomDomain = Remesh.domain({
type: MessageType.Normal,
date: Date.now(),
likeUsers: [],
hateUsers: []
hateUsers: [],
atUsers: typeof message === 'string' ? [] : message.atUsers
}
peerRoom.sendMessage(textMessage)

View file

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

View file

@ -1,51 +0,0 @@
import { type RefObject, useEffect, useRef } from 'react'
export type Events = Array<keyof GlobalEventHandlersEventMap>
/**
* Waiting for PR merge
* @see https://github.com/streamich/react-use/pull/2528
*/
const useClickAway = <E extends Event = Event>(
ref: RefObject<HTMLElement | null>,
onClickAway: (event: E) => void,
events: Events = ['mousedown', 'touchstart']
) => {
const savedCallback = useRef(onClickAway)
useEffect(() => {
savedCallback.current = onClickAway
}, [onClickAway])
useEffect(() => {
const { current: el } = ref
if (!el) return
const rootNode = el.getRootNode()
const isInShadow = rootNode instanceof ShadowRoot
/**
* When events are captured outside the component, events that occur in shadow DOM will target the host element
* so additional event listeners need to be added for shadowDom
*
* document shadowDom target
* | | |
* |- on(document) -|- on(shadowRoot) -|
*/
const handler = (event: SafeAny) => {
!el.contains(event.target) && event.target.shadowRoot !== rootNode && savedCallback.current(event)
}
for (const eventName of events) {
// eslint-disable-next-line @eslint-react/web-api/no-leaked-event-listener
document.addEventListener(eventName, handler)
// eslint-disable-next-line @eslint-react/web-api/no-leaked-event-listener
isInShadow && rootNode.addEventListener(eventName, handler)
}
return () => {
for (const eventName of events) {
document.removeEventListener(eventName, handler)
isInShadow && rootNode.removeEventListener(eventName, handler)
}
}
}, [events, ref])
}
export default useClickAway

View file

@ -0,0 +1,41 @@
import { RefCallback, useCallback, useRef, useState } from 'react'
import getCursorPosition, { Position } from '@/utils/getCursorPosition'
const useCursorPosition = () => {
const [position, setPosition] = useState<Position>({ x: 0, y: 0, selectionStart: 0, selectionEnd: 0 })
const handler = async (e: Event) => {
const newPosition = await getCursorPosition(e.target as HTMLInputElement | HTMLTextAreaElement)
if (JSON.stringify(newPosition) !== JSON.stringify(position)) {
setPosition(newPosition)
}
}
const handleRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null)
const setRef: RefCallback<HTMLInputElement | HTMLTextAreaElement | null> = useCallback(
(node) => {
if (handleRef.current) {
handleRef.current.removeEventListener('click', handler)
handleRef.current.removeEventListener('input', handler)
handleRef.current.removeEventListener('keydown', handler)
handleRef.current.removeEventListener('keyup', handler)
}
if (node) {
node.addEventListener('click', handler)
node.addEventListener('input', handler)
node.addEventListener('keydown', handler)
node.addEventListener('keyup', handler)
}
handleRef.current = node
},
[handler]
)
return {
...position,
setRef
}
}
export default useCursorPosition

View file

@ -18,7 +18,10 @@ const useDarg = (options: DargOptions) => {
const [position, setPosition] = useState({ x: clamp(initX, minX, maxX), y: clamp(initY, minY, maxY) })
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])
const isMove = useRef(false)
@ -68,7 +71,7 @@ const useDarg = (options: DargOptions) => {
const handleRef = useRef<HTMLElement | null>(null)
const setHandleRef = useCallback(
const setRef = useCallback(
(node: HTMLElement | null) => {
if (handleRef.current) {
handleRef.current.removeEventListener('mousedown', handleStart)
@ -85,7 +88,7 @@ const useDarg = (options: DargOptions) => {
[handleEnd, handleMove, handleStart]
)
return { ref: setHandleRef, ...position }
return { setRef, ...position }
}
export default useDarg

View file

@ -1,4 +1,4 @@
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
import { RefCallback, useCallback, useLayoutEffect, useRef, useState } from 'react'
import { clamp, isInRange } from '@/utils'
export interface ResizableOptions {
@ -14,8 +14,11 @@ const useResizable = (options: ResizableOptions) => {
const [size, setSize] = useState(clamp(initSize, minSize, maxSize))
useLayoutEffect(() => {
setSize(clamp(initSize, minSize, maxSize))
}, [initSize, minSize, maxSize])
const newSize = clamp(initSize, minSize, maxSize)
if (newSize !== size) {
setSize(newSize)
}
}, [initSize, minSize, maxSize, size])
const position = useRef(0)
@ -74,8 +77,8 @@ const useResizable = (options: ResizableOptions) => {
const handlerRef = useRef<HTMLElement | null>(null)
// Watch ref: https://medium.com/@teh_builder/ref-objects-inside-useeffect-hooks-eb7c15198780
const setHandleRef = useCallback(
(node: HTMLElement | null) => {
const setRef: RefCallback<HTMLElement | null> = useCallback(
(node) => {
if (handlerRef.current) {
handlerRef.current.removeEventListener('mousedown', handleStart)
document.removeEventListener('mouseup', handleEnd)
@ -91,7 +94,7 @@ const useResizable = (options: ResizableOptions) => {
[handleEnd, handleMove, handleStart]
)
return { size, ref: setHandleRef }
return { size, setRef }
}
export default useResizable

21
src/hooks/useShareRef.ts Normal file
View file

@ -0,0 +1,21 @@
import { ForwardedRef, MutableRefObject, RefCallback, useCallback } from 'react'
const useShareRef = <T extends HTMLElement | null>(
...refs: (MutableRefObject<T> | ForwardedRef<T> | RefCallback<T>)[]
) => {
const setRef = useCallback(
(node: T) =>
refs.forEach((ref) => {
if (typeof ref === 'function') {
ref(node)
} else if (ref) {
ref.current = node
}
}),
[...refs]
)
return setRef
}
export default useShareRef

View file

@ -0,0 +1,52 @@
import { RefCallback, useCallback, useRef } from 'react'
export type Events = Array<keyof GlobalEventHandlersEventMap>
/**
* @see https://github.com/streamich/react-use/pull/2528
*/
const useTriggerAway = <E extends Event = Event>(events: Events, callback: (event: E) => void) => {
const handleRef = useRef<HTMLElement | null>(null)
const handler = (event: SafeAny) => {
const rootNode = handleRef.current?.getRootNode()
!handleRef.current?.contains(event.target) && event.target.shadowRoot !== rootNode && callback(event)
}
/**
* When events are captured outside the component, events that occur in shadow DOM will target the host element
* so additional event listeners need to be added for shadowDom
*
* document shadowDom target
* | | |
* |- on(document) -|- on(shadowRoot) -|
*/
const setRef: RefCallback<HTMLElement | null> = useCallback(
(node) => {
if (handleRef.current) {
const rootNode = handleRef.current.getRootNode()
const isInShadow = rootNode instanceof ShadowRoot
events.forEach(() => {
for (const eventName of events) {
document.removeEventListener(eventName, handler)
isInShadow && rootNode.removeEventListener(eventName, handler)
}
})
}
if (node) {
const rootNode = node.getRootNode()
const isInShadow = rootNode instanceof ShadowRoot
events.forEach((eventName) => {
document.addEventListener(eventName, handler)
isInShadow && rootNode.addEventListener(eventName, handler)
})
}
handleRef.current = node
},
[handler]
)
return { setRef }
}
export default useTriggerAway

View file

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

View file

@ -0,0 +1,45 @@
/**
* Calculates the length of the Longest Common Subsequence (LCS) between two strings.
* @param a - The first string.
* @param b - The second string.
* @returns The length of the longest common subsequence.
* @see https://en.wikipedia.org/wiki/Longest_common_subsequence
*/
const getTextLCS = (a: string, b: string): number => {
// Create a 2D array to store the lengths of longest common subsequences
const dp: number[][] = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0))
// Fill the dp array
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
// If characters match, increment the length of the LCS found so far
if (a[i - 1] === b[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1
} else {
// If characters do not match, take the maximum length from the previous computations
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
}
}
}
// The length of the longest common subsequence is found in the bottom-right cell of the dp array
return dp[a.length][b.length]
}
/**
* Calculates the similarity between two strings based on their longest common subsequence.
* @param a - The first string.
* @param b - The second string.
* @returns A number representing the similarity between the two strings (0 to 1).
*/
const getTextSimilarity = (a: string, b: string): number => {
// Get the length of the longest common subsequence
const lcsLength: number = getTextLCS(a, b)
// Get the maximum length of the two strings
const maxLength: number = Math.max(a.length, b.length)
// Calculate similarity based on the length of the LCS
return maxLength === 0 ? 0 : lcsLength / maxLength
}
export default getTextSimilarity

View file

@ -11,3 +11,5 @@ export { default as throttle } from './throttle'
export { chunk, desert, upsert } from './array'
export { default as generateRandomAvatar } from './generateRandomAvatar'
export { default as generateRandomName } from './generateRandomName'
export { default as getCursorPosition } from './getCursorPosition'
export { default as getTextSimilarity } from './getTextSimilarity'