feat(ai): add ai mention plugin (#4305)
* init * refactor * implement mention plugin for chat input - Updated pnpm-lock.yaml and pnpm-workspace.yaml to include new versions of @floating-ui packages. - Added MentionPlugin to the chat input component, enabling mention functionality. - Created necessary components, hooks, and utilities for mention handling, including MentionNode, MentionDropdown, and related styles. - Refactored existing code to integrate mention features seamlessly into the chat interface. This update enhances the chat experience by allowing users to mention others easily, improving interactivity and engagement. Signed-off-by: Innei <tukon479@gmail.com> * Enhance mention functionality in chat editor - Integrated MentionNode into AIRichTextMessage for improved mention handling. - Updated MentionPlugin to utilize new hooks for mention search and insertion. - Added useMentionIntegration and useMentionSearchService hooks for better mention management. - Created contextBarSearchService and feedEntrySearchService for unified search functionality. - Refactored LexicalRichEditor to support custom nodes from plugins. This update improves the chat experience by streamlining mention interactions and enhancing search capabilities. Signed-off-by: Innei <tukon479@gmail.com> * Enhance mention integration and synchronization in chat editor - Updated ChatInput layout for improved button positioning. - Added select-none class to mention styles for better user experience. - Refactored MentionDropdown to improve the display of mention types and content. - Introduced useMentionBlockSync hook for bidirectional synchronization between mention nodes and context blocks. - Enhanced useMentionIntegration to handle mention insertion with node key tracking. - Implemented useMentionSearchService for streamlined mention search functionality. These changes improve the overall mention handling and user interaction within the chat editor, making it more intuitive and efficient. Signed-off-by: Innei <tukon479@gmail.com> * Refactor ChatInput layout to include ScrollArea for improved scrolling experience - Wrapped LexicalRichEditor in a ScrollArea component to enhance the input area’s usability. - Adjusted styles in LexicalRichEditor for better layout consistency. These changes aim to improve user interaction within the chat input by providing a more flexible scrolling interface. Signed-off-by: Innei <tukon479@gmail.com> * Remove deprecated mention functionality and update MentionNode for markdown export - Deleted mention-related files and types as functionality has been moved to the desktop app. - Updated MentionNode to change the text content format for markdown export. - Refactored useMentionBlockSync to remove unused helper functions and streamline dependencies. - Adjusted LexicalRichEditor placeholder styles for better alignment. These changes clean up the codebase by removing obsolete mention features and enhancing the current mention handling in the chat editor. Signed-off-by: Innei <tukon479@gmail.com> * Implement AI chat message tracking functionality - Added tracking for AI chat messages sent by integrating the tracker module into the ChatInterface component. - Introduced a new tracker point for AIChatMessageSent in the TrackerMapper enum. - Removed deprecated entry tracking methods to streamline the tracking process. These changes enhance the analytics capabilities of the chat interface by monitoring AI interactions more effectively. Signed-off-by: Innei <tukon479@gmail.com> * Update VSCode settings for Tailwind CSS and i18n-ally configuration - Streamlined the tailwindCSS.experimental.classRegex by removing unnecessary line breaks for better readability. - Consolidated the tailwindCSS.experimental.configFile and i18n-ally settings into single-line arrays for consistency. - Ensured proper formatting and alignment of settings to enhance maintainability. These changes improve the clarity and organization of the VSCode settings file. Signed-off-by: Innei <tukon479@gmail.com> --------- Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
parent
4205d49b08
commit
c4ac1b1875
|
|
@ -1,28 +0,0 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(pnpm run lint:*)",
|
||||
"Bash(rg:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(pnpm run:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(rm:*)",
|
||||
"Bash(pnpm install:*)",
|
||||
"Bash(mv:*)",
|
||||
"Bash(node:*)",
|
||||
"Bash(npx eslint:*)",
|
||||
"Bash(pnpm typecheck:*)",
|
||||
"Bash(chmod:*)",
|
||||
"Bash(./rename_i18n_keys.sh:*)",
|
||||
"mcp__context7__resolve-library-id",
|
||||
"mcp__context7__get-library-docs",
|
||||
"Bash(pnpm add:*)",
|
||||
"Bash(pnpm -w run typecheck)",
|
||||
"Bash(pnpm --filter @follow/components run typecheck)",
|
||||
"Bash(pnpm -w run lint:fix)",
|
||||
"Bash(pnpm exec tsc:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import type { LexicalRichEditorRef } from "@follow/components/ui/lexical-rich-editor/index.js"
|
||||
import { LexicalRichEditor } from "@follow/components/ui/lexical-rich-editor/index.js"
|
||||
import { ScrollArea } from "@follow/components/ui/scroll-area/ScrollArea.js"
|
||||
import { cn, stopPropagation } from "@follow/utils"
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
|
@ -10,6 +11,7 @@ import { memo, useCallback, useRef, useState } from "react"
|
|||
import { AIChatContextBar } from "~/modules/ai/chat/components/AIChatContextBar"
|
||||
|
||||
import { useChatActions, useChatError, useChatStatus } from "../../__internal__/hooks"
|
||||
import { MentionPlugin } from "../../editor"
|
||||
import { AIChatSendButton } from "./AIChatSendButton"
|
||||
import { CollapsibleError } from "./CollapsibleError"
|
||||
|
||||
|
|
@ -91,16 +93,19 @@ export const ChatInput = memo(({ onSend, variant }: ChatInputProps) => {
|
|||
<div className={cn(chatInputVariants({ variant }))}>
|
||||
{/* Input Area */}
|
||||
<div className="relative z-10 flex items-end" onContextMenu={stopPropagation}>
|
||||
<LexicalRichEditor
|
||||
ref={editorRef}
|
||||
placeholder="Message AI assistant..."
|
||||
className="w-full"
|
||||
onChange={handleEditorChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
namespace="AIChatRichEditor"
|
||||
/>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<ScrollArea rootClassName="mx-5 my-3.5 mr-14 flex-1 overflow-auto">
|
||||
<LexicalRichEditor
|
||||
ref={editorRef}
|
||||
placeholder="Message AI assistant..."
|
||||
className="w-full"
|
||||
onChange={handleEditorChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
plugins={[MentionPlugin]}
|
||||
namespace="AIChatRichEditor"
|
||||
/>
|
||||
</ScrollArea>
|
||||
<div className="absolute right-3 top-3">
|
||||
<AIChatSendButton
|
||||
onClick={isProcessing ? stop : handleSend}
|
||||
disabled={!isProcessing && isEmpty}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { ScrollArea } from "@follow/components/ui/scroll-area/ScrollArea.js"
|
||||
import { tracker } from "@follow/tracker"
|
||||
import { cn, nextFrame } from "@follow/utils"
|
||||
import { springScrollTo } from "@follow/utils/scroller"
|
||||
import type { BizUIMessage } from "@folo-services/ai-tools"
|
||||
|
|
@ -125,6 +126,7 @@ export const ChatInterface = () => {
|
|||
role: "user",
|
||||
id: nanoid(),
|
||||
})
|
||||
tracker.aiChatMessageSent()
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"
|
|||
import type { SerializedEditorState } from "lexical"
|
||||
import * as React from "react"
|
||||
|
||||
import { MentionNode } from "../../editor/plugins/mention/MentionNode"
|
||||
|
||||
function onError(error: Error) {
|
||||
console.error("Lexical Read-Only Editor Error:", error)
|
||||
}
|
||||
|
|
@ -29,7 +31,7 @@ export const AIRichTextMessage: React.FC<AIRichTextMessageProps> = React.memo(
|
|||
onError,
|
||||
editable: false, // Read-only mode
|
||||
editorState: JSON.stringify(data.state),
|
||||
nodes: LexicalRichEditorNodes,
|
||||
nodes: [...LexicalRichEditorNodes, MentionNode],
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export * from "./plugins"
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from "./mention"
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
import type {
|
||||
DOMConversionMap,
|
||||
DOMConversionOutput,
|
||||
DOMExportOutput,
|
||||
EditorConfig,
|
||||
LexicalEditor,
|
||||
LexicalNode,
|
||||
NodeKey,
|
||||
SerializedLexicalNode,
|
||||
Spread,
|
||||
} from "lexical"
|
||||
import { $applyNodeReplacement, DecoratorNode } from "lexical"
|
||||
import * as React from "react"
|
||||
|
||||
import type { MentionData } from "./types"
|
||||
|
||||
export type SerializedMentionNode = Spread<
|
||||
{
|
||||
mentionData: MentionData
|
||||
},
|
||||
SerializedLexicalNode
|
||||
>
|
||||
|
||||
const MentionComponent = React.lazy(() =>
|
||||
import("./components/MentionComponent").then((module) => ({
|
||||
default: module.MentionComponent,
|
||||
})),
|
||||
)
|
||||
|
||||
export class MentionNode extends DecoratorNode<React.JSX.Element> {
|
||||
__mentionData: MentionData
|
||||
|
||||
static override getType(): string {
|
||||
return "mention"
|
||||
}
|
||||
|
||||
static override clone(node: MentionNode): MentionNode {
|
||||
return new MentionNode(node.__mentionData, node.__key)
|
||||
}
|
||||
|
||||
constructor(mentionData: MentionData, key?: NodeKey) {
|
||||
super(key)
|
||||
this.__mentionData = mentionData
|
||||
}
|
||||
|
||||
override createDOM(config: EditorConfig): HTMLElement {
|
||||
const dom = document.createElement("span")
|
||||
dom.className = config.theme.mention || "mention-node"
|
||||
dom.dataset.lexicalMention = "true"
|
||||
dom.dataset.mentionType = this.__mentionData.type
|
||||
dom.dataset.mentionId = this.__mentionData.id
|
||||
return dom
|
||||
}
|
||||
|
||||
override updateDOM(): false {
|
||||
return false
|
||||
}
|
||||
|
||||
static override importDOM(): DOMConversionMap | null {
|
||||
return {
|
||||
span: (domNode: HTMLElement) => {
|
||||
if (!Object.hasOwn(domNode.dataset, "lexicalMention")) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
conversion: convertMentionElement,
|
||||
priority: 1,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
static override importJSON(serializedNode: SerializedMentionNode): MentionNode {
|
||||
const { mentionData } = serializedNode
|
||||
const node = $createMentionNode(mentionData)
|
||||
return node
|
||||
}
|
||||
|
||||
override exportDOM(): DOMExportOutput {
|
||||
const element = document.createElement("span")
|
||||
element.dataset.lexicalMention = "true"
|
||||
element.dataset.mentionType = this.__mentionData.type
|
||||
element.dataset.mentionId = this.__mentionData.id
|
||||
element.textContent = `@${this.__mentionData.name}`
|
||||
element.className = "mention-node"
|
||||
return { element }
|
||||
}
|
||||
|
||||
override exportJSON(): SerializedMentionNode {
|
||||
return {
|
||||
mentionData: this.__mentionData,
|
||||
type: "mention",
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For export markdown conversion
|
||||
*/
|
||||
override getTextContent(): string {
|
||||
return `[[ref:${this.__mentionData.type}:${this.__mentionData.value}]]`
|
||||
}
|
||||
|
||||
override decorate(_editor: LexicalEditor): React.JSX.Element {
|
||||
return (
|
||||
<React.Suspense fallback={<span>@{this.__mentionData.name}</span>}>
|
||||
<MentionComponent mentionData={this.__mentionData} />
|
||||
</React.Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
override isInline(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override isKeyboardSelectable(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
canInsertTextBefore(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
canInsertTextAfter(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
canBeEmpty(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
isSegmented(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
extractWithChild(): boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function convertMentionElement(domNode: HTMLElement): DOMConversionOutput {
|
||||
const mentionType = domNode.dataset.mentionType as MentionData["type"] | null
|
||||
const { mentionId } = domNode.dataset
|
||||
const textContent = domNode.textContent || ""
|
||||
|
||||
if (!mentionType || !mentionId) {
|
||||
return { node: null }
|
||||
}
|
||||
|
||||
// Extract name from text content (remove @ prefix)
|
||||
const name = textContent.startsWith("@") ? textContent.slice(1) : textContent
|
||||
|
||||
const mentionData: MentionData = {
|
||||
id: mentionId,
|
||||
name,
|
||||
type: mentionType,
|
||||
value: null,
|
||||
}
|
||||
|
||||
const node = $createMentionNode(mentionData)
|
||||
return { node }
|
||||
}
|
||||
|
||||
export function $createMentionNode(mentionData: MentionData): MentionNode {
|
||||
const mentionNode = new MentionNode(mentionData)
|
||||
return $applyNodeReplacement(mentionNode)
|
||||
}
|
||||
|
||||
export function $isMentionNode(node: LexicalNode | null | undefined): node is MentionNode {
|
||||
return node instanceof MentionNode
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
import * as React from "react"
|
||||
import { Suspense, useMemo } from "react"
|
||||
|
||||
import { MentionDropdown } from "./components/MentionDropdown"
|
||||
import { DEFAULT_MAX_SUGGESTIONS } from "./constants"
|
||||
import { useMentionIntegration } from "./hooks/useMentionIntegration"
|
||||
import { useMentionKeyboard } from "./hooks/useMentionKeyboard"
|
||||
import { useMentionSearch } from "./hooks/useMentionSearch"
|
||||
import { useMentionSelection } from "./hooks/useMentionSelection"
|
||||
import { useMentionTrigger } from "./hooks/useMentionTrigger"
|
||||
import { MentionNode } from "./MentionNode"
|
||||
import { defaultTriggerFn } from "./utils/triggerDetection"
|
||||
|
||||
export function MentionPlugin() {
|
||||
// Get integrated search and context block handling
|
||||
const { searchMentions, handleMentionInsert } = useMentionIntegration()
|
||||
|
||||
const { maxSuggestions, triggerFn } = {
|
||||
maxSuggestions: DEFAULT_MAX_SUGGESTIONS,
|
||||
triggerFn: defaultTriggerFn,
|
||||
}
|
||||
// Hook for detecting mention triggers
|
||||
const { mentionMatch, isActive, clearMentionMatch } = useMentionTrigger({
|
||||
triggerFn,
|
||||
})
|
||||
|
||||
// Hook for searching mentions
|
||||
const {
|
||||
suggestions,
|
||||
selectedIndex,
|
||||
isLoading,
|
||||
searchMentions: performSearch,
|
||||
clearSuggestions,
|
||||
setSelectedIndex,
|
||||
hasResults,
|
||||
} = useMentionSearch({
|
||||
onSearch: searchMentions,
|
||||
maxSuggestions,
|
||||
})
|
||||
|
||||
// Hook for handling mention selection
|
||||
const { selectMention } = useMentionSelection({
|
||||
mentionMatch,
|
||||
onMentionInsert: handleMentionInsert,
|
||||
onSelectionComplete: () => {
|
||||
clearMentionMatch()
|
||||
clearSuggestions()
|
||||
},
|
||||
})
|
||||
|
||||
// Hook for keyboard navigation
|
||||
const handleArrowKey = React.useCallback(
|
||||
(isUp: boolean) => {
|
||||
if (!hasResults) return
|
||||
|
||||
const newIndex = isUp
|
||||
? selectedIndex <= 0
|
||||
? suggestions.length - 1
|
||||
: selectedIndex - 1
|
||||
: selectedIndex >= suggestions.length - 1
|
||||
? 0
|
||||
: selectedIndex + 1
|
||||
|
||||
setSelectedIndex(newIndex)
|
||||
},
|
||||
[hasResults, suggestions.length, selectedIndex, setSelectedIndex],
|
||||
)
|
||||
|
||||
const handleEnterKey = React.useCallback(() => {
|
||||
if (hasResults && selectedIndex >= 0 && selectedIndex < suggestions.length) {
|
||||
const mention = suggestions[selectedIndex]
|
||||
if (mention) {
|
||||
selectMention(mention)
|
||||
}
|
||||
}
|
||||
}, [hasResults, selectedIndex, suggestions, selectMention])
|
||||
|
||||
const handleEscapeKey = React.useCallback(() => {
|
||||
clearMentionMatch()
|
||||
clearSuggestions()
|
||||
}, [clearMentionMatch, clearSuggestions])
|
||||
|
||||
useMentionKeyboard({
|
||||
isActive,
|
||||
suggestions,
|
||||
selectedIndex,
|
||||
onArrowKey: handleArrowKey,
|
||||
onEnterKey: handleEnterKey,
|
||||
onEscapeKey: handleEscapeKey,
|
||||
})
|
||||
|
||||
// Search when mention match changes
|
||||
React.useEffect(() => {
|
||||
if (mentionMatch) {
|
||||
performSearch(mentionMatch.matchingString)
|
||||
} else {
|
||||
clearSuggestions()
|
||||
}
|
||||
}, [mentionMatch, performSearch, clearSuggestions])
|
||||
|
||||
// Calculate dropdown props
|
||||
const dropdownProps = useMemo(() => {
|
||||
if (!isActive || !hasResults) return null
|
||||
|
||||
return {
|
||||
isVisible: true,
|
||||
suggestions,
|
||||
selectedIndex,
|
||||
isLoading,
|
||||
onSelect: selectMention,
|
||||
onClose: handleEscapeKey,
|
||||
query: mentionMatch?.matchingString || "",
|
||||
}
|
||||
}, [
|
||||
isActive,
|
||||
hasResults,
|
||||
suggestions,
|
||||
selectedIndex,
|
||||
isLoading,
|
||||
selectMention,
|
||||
handleEscapeKey,
|
||||
mentionMatch,
|
||||
])
|
||||
|
||||
return dropdownProps ? (
|
||||
<Suspense fallback={null}>
|
||||
<MentionDropdown {...dropdownProps} />
|
||||
</Suspense>
|
||||
) : null
|
||||
}
|
||||
|
||||
MentionPlugin.id = "mention"
|
||||
MentionPlugin.nodes = [MentionNode]
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
TooltipRoot,
|
||||
TooltipTrigger,
|
||||
} from "@follow/components/ui/tooltip/index.js"
|
||||
import { cn } from "@follow/utils"
|
||||
import * as React from "react"
|
||||
|
||||
import type { MentionData } from "../types"
|
||||
import { MentionTypeIcon } from "./shared/MentionTypeIcon"
|
||||
|
||||
interface MentionComponentProps {
|
||||
mentionData: MentionData
|
||||
className?: string
|
||||
}
|
||||
|
||||
const MentionTooltipContent = ({ mentionData }: { mentionData: MentionData }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-fill border-fill-secondary flex size-6 flex-shrink-0 items-center justify-center rounded-full border">
|
||||
<MentionTypeIcon type={mentionData.type} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-text text-sm font-medium">@{mentionData.name}</p>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5 text-xs font-medium",
|
||||
mentionData.type === "entry" && "bg-blue text-black",
|
||||
mentionData.type === "feed" && "bg-orange text-black",
|
||||
)}
|
||||
>
|
||||
{mentionData.type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const getMentionStyles = (type: MentionData["type"]) => {
|
||||
const baseStyles = tw`
|
||||
inline-flex items-center gap-1 px-2 py-0.5 rounded-md
|
||||
font-medium text-sm cursor-pointer select-none
|
||||
`
|
||||
|
||||
switch (type) {
|
||||
case "entry": {
|
||||
return cn(
|
||||
baseStyles,
|
||||
"bg-blue/10 text-blue border-blue/20",
|
||||
"hover:bg-blue/20 hover:border-blue/30",
|
||||
)
|
||||
}
|
||||
case "feed": {
|
||||
return cn(
|
||||
baseStyles,
|
||||
"bg-orange/10 text-orange border-orange/20",
|
||||
"hover:bg-orange/20 hover:border-orange/30",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
export const MentionComponent: React.FC<MentionComponentProps> = ({ mentionData, className }) => {
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
// Handle mention click - could navigate to user profile, topic page, etc.
|
||||
// TODO: Implement navigation logic for mentions
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn(getMentionStyles(mentionData.type), className)} onClick={handleClick}>
|
||||
<MentionTypeIcon type={mentionData.type} />
|
||||
<span>@{mentionData.name}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent side="top" className="max-w-[300px]">
|
||||
<MentionTooltipContent mentionData={mentionData} />
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</TooltipRoot>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
MentionComponent.displayName = "MentionComponent"
|
||||
|
|
@ -0,0 +1,266 @@
|
|||
import {
|
||||
autoUpdate,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
useDismiss,
|
||||
useFloating,
|
||||
useInteractions,
|
||||
useRole,
|
||||
} from "@floating-ui/react"
|
||||
import { RootPortal } from "@follow/components/ui/portal/index.js"
|
||||
import { cn, thenable } from "@follow/utils"
|
||||
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
|
||||
import * as React from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
|
||||
import type { MentionData } from "../types"
|
||||
import { calculateDropdownPosition } from "../utils/positioning"
|
||||
import { MentionTypeIcon } from "./shared/MentionTypeIcon"
|
||||
|
||||
interface MentionDropdownProps {
|
||||
isVisible: boolean
|
||||
suggestions: MentionData[]
|
||||
selectedIndex: number
|
||||
isLoading: boolean
|
||||
onSelect: (mention: MentionData) => void
|
||||
onClose: () => void
|
||||
query: string
|
||||
anchor?: HTMLElement | null
|
||||
}
|
||||
|
||||
const MentionSuggestionItem = React.memo(
|
||||
({
|
||||
mention,
|
||||
isSelected,
|
||||
onClick,
|
||||
query,
|
||||
}: {
|
||||
mention: MentionData
|
||||
isSelected: boolean
|
||||
onClick: (mention: MentionData) => void
|
||||
query: string
|
||||
}) => {
|
||||
const handleClick = useCallback(() => {
|
||||
onClick(mention)
|
||||
}, [mention, onClick])
|
||||
|
||||
// Highlight matching text
|
||||
const highlightText = (text: string, query: string) => {
|
||||
const cleanQuery = query.replace("@", "").toLowerCase()
|
||||
if (!cleanQuery) return text
|
||||
|
||||
const parts = text.split(new RegExp(`(${cleanQuery})`, "gi"))
|
||||
return parts.map((part, partIndex) => {
|
||||
const isMatch = part.toLowerCase() === cleanQuery
|
||||
// Create a more unique key that doesn't rely solely on index
|
||||
const uniqueKey = `${mention.id}-${mention.type}-${part}-${partIndex}-${part.length}`
|
||||
return (
|
||||
<span key={uniqueKey} className={isMatch ? "text-text-vibrant font-semibold" : ""}>
|
||||
{part}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"cursor-menu relative flex select-none items-center rounded-[5px] px-2.5 py-1 outline-none",
|
||||
"focus-within:outline-transparent",
|
||||
"data-[highlighted]:bg-theme-selection-hover focus:bg-theme-selection-active focus:text-theme-selection-foreground data-[highlighted]:text-theme-selection-foreground",
|
||||
"h-[28px]",
|
||||
isSelected && "bg-theme-selection-active text-theme-selection-foreground",
|
||||
)}
|
||||
onClick={handleClick}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
>
|
||||
{/* Icon */}
|
||||
<span
|
||||
className={cn(
|
||||
"mr-1.5 inline-flex size-4 items-center justify-center",
|
||||
mention.type === "entry" && "text-blue-500",
|
||||
mention.type === "feed" && "text-orange-500",
|
||||
)}
|
||||
>
|
||||
<MentionTypeIcon type={mention.type} />
|
||||
</span>
|
||||
|
||||
{/* Content */}
|
||||
<span className="flex-1 truncate">{highlightText(mention.name, query)}</span>
|
||||
|
||||
{/* Selection Indicator */}
|
||||
{isSelected && (
|
||||
<span className="ml-1.5 inline-flex size-4 items-center justify-center">
|
||||
<i className="i-mgc-check-cute-re size-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
MentionSuggestionItem.displayName = "MentionSuggestionItem"
|
||||
|
||||
export const MentionDropdown: React.FC<MentionDropdownProps> = ({
|
||||
isVisible,
|
||||
suggestions,
|
||||
selectedIndex,
|
||||
isLoading,
|
||||
onSelect,
|
||||
onClose,
|
||||
query,
|
||||
}) => {
|
||||
if (!isVisible) throw thenable
|
||||
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const [referenceWidth, setReferenceWidth] = useState<number>(320)
|
||||
|
||||
// Create virtual reference element based on cursor position
|
||||
const virtualReference = useRef({
|
||||
getBoundingClientRect: () => {
|
||||
const position = calculateDropdownPosition(editor)
|
||||
const editorElement = editor.getRootElement()
|
||||
|
||||
if (!position || !editorElement) {
|
||||
// Fallback to editor element
|
||||
return (
|
||||
editorElement?.getBoundingClientRect() || {
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const editorRect = editorElement.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
top: editorRect.top + position.top,
|
||||
left: editorRect.left + position.left,
|
||||
bottom: editorRect.top + position.top,
|
||||
right: editorRect.left + position.left,
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: editorRect.left + position.left,
|
||||
y: editorRect.top + position.top,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const { refs, floatingStyles, context } = useFloating({
|
||||
open: isVisible,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) onClose()
|
||||
},
|
||||
middleware: [
|
||||
offset(8),
|
||||
flip({ fallbackPlacements: ["bottom-start", "top-start", "bottom-end", "top-end"] }),
|
||||
shift({ padding: 8 }),
|
||||
],
|
||||
whileElementsMounted: autoUpdate,
|
||||
placement: "bottom-start",
|
||||
})
|
||||
|
||||
const dismiss = useDismiss(context, {
|
||||
enabled: isVisible,
|
||||
})
|
||||
|
||||
const role = useRole(context, {
|
||||
role: "listbox",
|
||||
})
|
||||
|
||||
const { getFloatingProps } = useInteractions([dismiss, role])
|
||||
|
||||
// Handle scroll to keep selected item in view
|
||||
useEffect(() => {
|
||||
if (isVisible && dropdownRef.current && selectedIndex >= 0) {
|
||||
const listContainer = dropdownRef.current.querySelector('[role="listbox"]')
|
||||
if (listContainer) {
|
||||
const selectedElement = listContainer.children[selectedIndex] as HTMLElement
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [selectedIndex, isVisible])
|
||||
|
||||
// Set virtual reference element based on cursor position and calculate width
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
refs.setReference(virtualReference.current)
|
||||
|
||||
const editorElement = editor.getRootElement()
|
||||
if (editorElement) {
|
||||
const rect = editorElement.getBoundingClientRect()
|
||||
setReferenceWidth(rect.width || 320)
|
||||
}
|
||||
}
|
||||
}, [editor, refs, isVisible, query]) // Add query as dependency to update position when typing
|
||||
|
||||
return (
|
||||
<RootPortal>
|
||||
{isVisible && (
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
style={floatingStyles}
|
||||
className="z-[1000]"
|
||||
{...getFloatingProps()}
|
||||
>
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className={cn(
|
||||
"bg-material-medium backdrop-blur-background text-text shadow-context-menu",
|
||||
"min-w-32 overflow-hidden rounded-[6px] border p-1",
|
||||
"text-body",
|
||||
)}
|
||||
style={{
|
||||
width: Math.max(referenceWidth, 200),
|
||||
maxWidth: 320,
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="text-text-secondary flex items-center gap-2 px-2.5 py-1.5">
|
||||
<i className="i-mgc-loading-3-cute-re size-4 animate-spin" />
|
||||
<span className="text-sm">Searching...</span>
|
||||
</div>
|
||||
) : suggestions.length === 0 ? (
|
||||
<div className="text-text-tertiary px-2.5 py-1.5 text-center">
|
||||
<span className="text-sm">No matches found</span>
|
||||
{query && (
|
||||
<div className="text-text-quaternary mt-1 text-xs">
|
||||
Try a different search term
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div role="listbox" aria-label="Mention suggestions">
|
||||
{suggestions.map((mention, index) => (
|
||||
<MentionSuggestionItem
|
||||
key={`${mention.type}-${mention.id}`}
|
||||
mention={mention}
|
||||
isSelected={index === selectedIndex}
|
||||
onClick={onSelect}
|
||||
query={query}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</RootPortal>
|
||||
)
|
||||
}
|
||||
|
||||
MentionDropdown.displayName = "MentionDropdown"
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import * as React from "react"
|
||||
|
||||
import type { MentionType } from "../../types"
|
||||
|
||||
interface MentionTypeIconProps {
|
||||
type: MentionType
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const MentionTypeIcon: React.FC<MentionTypeIconProps> = ({ type, className = "size-3" }) => {
|
||||
switch (type) {
|
||||
case "entry": {
|
||||
return <i className={`i-mgc-paper-cute-fi ${className}`} />
|
||||
}
|
||||
case "feed": {
|
||||
return <i className={`i-mgc-rss-cute-fi ${className}`} />
|
||||
}
|
||||
default: {
|
||||
return <i className={`i-mgc-ai-cute-re ${className}`} />
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MentionTypeIcon.displayName = "MentionTypeIcon"
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { createCommand } from "lexical"
|
||||
|
||||
import type { MentionData } from "./types"
|
||||
|
||||
// Commands
|
||||
export const MENTION_COMMAND = createCommand<MentionData>("MENTION_COMMAND")
|
||||
export const MENTION_TYPEAHEAD_COMMAND = createCommand<string>("MENTION_TYPEAHEAD_COMMAND")
|
||||
|
||||
// Default configuration
|
||||
|
||||
export const DEFAULT_MAX_SUGGESTIONS = 10
|
||||
|
||||
// Trigger patterns
|
||||
export const MENTION_TRIGGER_PATTERN = /(?:^|\s)(@[\w-]*)$/
|
||||
export const FEED_MENTION_PATTERN = /(?:^|\s)(@#[\w-]*)$/
|
||||
export const ENTRY_MENTION_PATTERN = /(?:^|\s)(@\+[\w-]*)$/
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
|
||||
import { $getNodeByKey } from "lexical"
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
|
||||
import { useAIChatStore } from "~/modules/ai/chat/__internal__/AIChatContext"
|
||||
import { useChatBlockActions } from "~/modules/ai/chat/__internal__/hooks"
|
||||
import type { AIChatContextBlock } from "~/modules/ai/chat/__internal__/types"
|
||||
|
||||
import { $isMentionNode, MentionNode } from "../MentionNode"
|
||||
import type { MentionData } from "../types"
|
||||
|
||||
interface MentionBlockReference {
|
||||
mentionNodeKey: string
|
||||
blockId: string
|
||||
resourceId: string // `${type}:${value}`
|
||||
mentionData: MentionData
|
||||
}
|
||||
|
||||
const getResourceId = (type: string, value: string) => `${type}:${value}`
|
||||
|
||||
const getBlockType = (mentionType: string): AIChatContextBlock["type"] => {
|
||||
return mentionType === "feed" ? "referFeed" : "referEntry"
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that manages bidirectional synchronization between mention nodes and context blocks
|
||||
* - When a mention is added, corresponding block is created
|
||||
* - When a block is removed, corresponding mentions are removed
|
||||
* - When a mention is removed, corresponding block is removed (if no other mentions reference it)
|
||||
*/
|
||||
export const useMentionBlockSync = () => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const blockActions = useChatBlockActions()
|
||||
const blocks = useAIChatStore()((state) => state.blocks)
|
||||
|
||||
// Reference tracking maps
|
||||
const mentionToBlockRef = useRef(new Map<string, MentionBlockReference>()) // mentionNodeKey -> reference
|
||||
const resourceToMentionsRef = useRef(new Map<string, Set<string>>()) // resourceId -> Set<mentionNodeKey>
|
||||
const blockToResourceRef = useRef(new Map<string, string>()) // blockId -> resourceId
|
||||
|
||||
// Add mention-block reference
|
||||
const addMentionReference = useCallback(
|
||||
(mentionData: MentionData, mentionNodeKey: string, blockId: string) => {
|
||||
const resourceId = getResourceId(mentionData.type, mentionData.value as string)
|
||||
|
||||
const reference: MentionBlockReference = {
|
||||
mentionNodeKey,
|
||||
blockId,
|
||||
resourceId,
|
||||
mentionData,
|
||||
}
|
||||
|
||||
// Update tracking maps
|
||||
mentionToBlockRef.current.set(mentionNodeKey, reference)
|
||||
|
||||
if (!resourceToMentionsRef.current.has(resourceId)) {
|
||||
resourceToMentionsRef.current.set(resourceId, new Set())
|
||||
}
|
||||
resourceToMentionsRef.current.get(resourceId)!.add(mentionNodeKey)
|
||||
|
||||
blockToResourceRef.current.set(blockId, resourceId)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// Remove mention reference
|
||||
const removeMentionReference = useCallback((mentionNodeKey: string) => {
|
||||
const reference = mentionToBlockRef.current.get(mentionNodeKey)
|
||||
if (!reference) return null
|
||||
|
||||
const { resourceId, blockId } = reference
|
||||
|
||||
// Clean up tracking maps
|
||||
mentionToBlockRef.current.delete(mentionNodeKey)
|
||||
|
||||
const mentionSet = resourceToMentionsRef.current.get(resourceId)
|
||||
if (mentionSet) {
|
||||
mentionSet.delete(mentionNodeKey)
|
||||
if (mentionSet.size === 0) {
|
||||
resourceToMentionsRef.current.delete(resourceId)
|
||||
}
|
||||
}
|
||||
|
||||
blockToResourceRef.current.delete(blockId)
|
||||
|
||||
return reference
|
||||
}, [])
|
||||
|
||||
// Handle mention insertion - create block and track reference
|
||||
const handleMentionInsert = useCallback(
|
||||
(mentionData: MentionData, mentionNodeKey: string) => {
|
||||
const resourceId = getResourceId(mentionData.type, mentionData.value as string)
|
||||
|
||||
// Check if block already exists for this resource
|
||||
const existingBlock = blocks.find(
|
||||
(block) => blockToResourceRef.current.get(block.id) === resourceId,
|
||||
)
|
||||
|
||||
let blockId: string
|
||||
if (existingBlock) {
|
||||
// Use existing block
|
||||
blockId = existingBlock.id
|
||||
} else {
|
||||
// Create new block
|
||||
const blockType = getBlockType(mentionData.type)
|
||||
|
||||
// Generate block ID (mimicking the block slice logic)
|
||||
const newBlock: Omit<AIChatContextBlock, "id"> = {
|
||||
type: blockType,
|
||||
value: mentionData.value as string,
|
||||
}
|
||||
|
||||
blockActions.addBlock(newBlock)
|
||||
|
||||
// Use current blocks state directly from store instead of stale closure
|
||||
const currentBlocks = blockActions.getBlocks()
|
||||
const addedBlock = currentBlocks.find(
|
||||
(block) => block.type === blockType && block.value === mentionData.value,
|
||||
)
|
||||
|
||||
if (addedBlock) {
|
||||
blockId = addedBlock.id
|
||||
} else {
|
||||
// Fallback to a predictable ID pattern if we can't find the block immediately
|
||||
blockId = `${blockType}-${mentionData.value}-${mentionNodeKey}`
|
||||
}
|
||||
}
|
||||
|
||||
// Track the reference
|
||||
addMentionReference(mentionData, mentionNodeKey, blockId)
|
||||
},
|
||||
[blocks, blockActions, addMentionReference],
|
||||
)
|
||||
|
||||
// Handle mention removal - remove block if no other mentions reference it
|
||||
const handleMentionRemove = useCallback(
|
||||
(mentionNodeKey: string) => {
|
||||
const reference = removeMentionReference(mentionNodeKey)
|
||||
if (!reference) return
|
||||
|
||||
const { resourceId, blockId } = reference
|
||||
|
||||
// Check if any other mentions still reference this resource
|
||||
const remainingMentions = resourceToMentionsRef.current.get(resourceId)
|
||||
if (!remainingMentions || remainingMentions.size === 0) {
|
||||
// No more mentions reference this resource, remove the block
|
||||
|
||||
blockActions.removeBlock(blockId)
|
||||
}
|
||||
},
|
||||
[blockActions, removeMentionReference],
|
||||
)
|
||||
|
||||
// Handle block removal - remove corresponding mentions
|
||||
const handleBlockRemove = useCallback(
|
||||
(blockId: string) => {
|
||||
const resourceId = blockToResourceRef.current.get(blockId)
|
||||
if (!resourceId) return
|
||||
|
||||
const mentionKeys = resourceToMentionsRef.current.get(resourceId)
|
||||
if (!mentionKeys) return
|
||||
|
||||
// Remove all mention nodes for this resource
|
||||
editor.update(() => {
|
||||
Array.from(mentionKeys).forEach((mentionKey) => {
|
||||
const node = $getNodeByKey(mentionKey)
|
||||
if (node && $isMentionNode(node)) {
|
||||
node.remove()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Clean up references
|
||||
Array.from(mentionKeys).forEach((mentionKey) => {
|
||||
removeMentionReference(mentionKey)
|
||||
})
|
||||
},
|
||||
[editor, removeMentionReference],
|
||||
)
|
||||
|
||||
// Monitor block changes
|
||||
useEffect(() => {
|
||||
const currentBlockIds = new Set(blocks.map((block) => block.id))
|
||||
const trackedBlockIds = new Set(blockToResourceRef.current.keys())
|
||||
|
||||
// Find removed blocks
|
||||
for (const trackedBlockId of trackedBlockIds) {
|
||||
if (!currentBlockIds.has(trackedBlockId)) {
|
||||
handleBlockRemove(trackedBlockId)
|
||||
}
|
||||
}
|
||||
}, [blocks, handleBlockRemove])
|
||||
|
||||
// Monitor mention node changes using mutation observer
|
||||
useEffect(() => {
|
||||
const removedMentionKeys = new Set<string>()
|
||||
|
||||
const unregisterMutationListener = editor.registerMutationListener(
|
||||
MentionNode,
|
||||
(mutatedNodes) => {
|
||||
for (const [nodeKey, mutation] of mutatedNodes) {
|
||||
// Only track destroyed mutations for mentions we're actually tracking
|
||||
if (mutation === "destroyed" && mentionToBlockRef.current.has(nodeKey)) {
|
||||
removedMentionKeys.add(nodeKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Process removed mentions in next tick to avoid state conflicts
|
||||
if (removedMentionKeys.size > 0) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
Array.from(removedMentionKeys).forEach((mentionKey) => {
|
||||
handleMentionRemove(mentionKey)
|
||||
})
|
||||
removedMentionKeys.clear()
|
||||
}, 100) // Increase delay to avoid race conditions
|
||||
|
||||
// Store timeout ID for potential cleanup
|
||||
return () => clearTimeout(timeoutId)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return unregisterMutationListener
|
||||
}, [editor, handleMentionRemove])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
const mentionToBlock = mentionToBlockRef.current
|
||||
const resourceToMentions = resourceToMentionsRef.current
|
||||
const blockToResource = blockToResourceRef.current
|
||||
|
||||
return () => {
|
||||
mentionToBlock.clear()
|
||||
resourceToMentions.clear()
|
||||
blockToResource.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
handleMentionInsert,
|
||||
handleMentionRemove,
|
||||
// Debug helpers
|
||||
getMentionReferences: () => ({
|
||||
mentionToBlock: new Map(mentionToBlockRef.current),
|
||||
resourceToMentions: new Map(resourceToMentionsRef.current),
|
||||
blockToResource: new Map(blockToResourceRef.current),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { useCallback } from "react"
|
||||
|
||||
import type { MentionData } from "../types"
|
||||
import { useMentionBlockSync } from "./useMentionBlockSync"
|
||||
import { useMentionSearchService } from "./useMentionSearchService"
|
||||
|
||||
/**
|
||||
* Hook that integrates mention search with context block management
|
||||
* Provides search functionality and handles bidirectional synchronization
|
||||
*/
|
||||
export const useMentionIntegration = () => {
|
||||
const { searchMentions } = useMentionSearchService()
|
||||
const { handleMentionInsert: syncMentionInsert } = useMentionBlockSync()
|
||||
|
||||
// Handle mention insertion with node key tracking
|
||||
const handleMentionInsert = useCallback(
|
||||
(mention: MentionData, nodeKey?: string) => {
|
||||
if (nodeKey) {
|
||||
syncMentionInsert(mention, nodeKey)
|
||||
}
|
||||
},
|
||||
[syncMentionInsert],
|
||||
)
|
||||
|
||||
return {
|
||||
searchMentions,
|
||||
handleMentionInsert,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
|
||||
import {
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
KEY_ARROW_DOWN_COMMAND,
|
||||
KEY_ARROW_UP_COMMAND,
|
||||
KEY_ENTER_COMMAND,
|
||||
KEY_ESCAPE_COMMAND,
|
||||
KEY_TAB_COMMAND,
|
||||
} from "lexical"
|
||||
import { useCallback, useEffect } from "react"
|
||||
|
||||
import type { MentionData } from "../types"
|
||||
|
||||
interface UseMentionKeyboardOptions {
|
||||
isActive: boolean
|
||||
suggestions: MentionData[]
|
||||
selectedIndex: number
|
||||
onArrowKey: (isUp: boolean) => void
|
||||
onEnterKey: () => void
|
||||
onEscapeKey: () => void
|
||||
}
|
||||
|
||||
export const useMentionKeyboard = ({
|
||||
isActive,
|
||||
suggestions,
|
||||
selectedIndex,
|
||||
onArrowKey,
|
||||
onEnterKey,
|
||||
onEscapeKey,
|
||||
}: UseMentionKeyboardOptions) => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleArrowKey = useCallback(
|
||||
(isUp: boolean) => {
|
||||
if (!isActive || suggestions.length === 0) return false
|
||||
onArrowKey(isUp)
|
||||
return true
|
||||
},
|
||||
[isActive, suggestions.length, onArrowKey],
|
||||
)
|
||||
|
||||
// Handle enter key
|
||||
const handleEnterKey = useCallback(() => {
|
||||
if (
|
||||
!isActive ||
|
||||
suggestions.length === 0 ||
|
||||
selectedIndex < 0 ||
|
||||
selectedIndex >= suggestions.length
|
||||
) {
|
||||
return false
|
||||
}
|
||||
onEnterKey()
|
||||
return true
|
||||
}, [isActive, suggestions.length, selectedIndex, onEnterKey])
|
||||
|
||||
// Handle escape key
|
||||
const handleEscapeKey = useCallback(() => {
|
||||
if (!isActive) return false
|
||||
onEscapeKey()
|
||||
return true
|
||||
}, [isActive, onEscapeKey])
|
||||
|
||||
// Register keyboard commands
|
||||
useEffect(() => {
|
||||
const removeCommands = [
|
||||
// Arrow key navigation
|
||||
editor.registerCommand(
|
||||
KEY_ARROW_UP_COMMAND,
|
||||
(event) => {
|
||||
if (isActive && suggestions.length > 0) {
|
||||
event.preventDefault()
|
||||
return handleArrowKey(true)
|
||||
}
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
|
||||
editor.registerCommand(
|
||||
KEY_ARROW_DOWN_COMMAND,
|
||||
(event) => {
|
||||
if (isActive && suggestions.length > 0) {
|
||||
event.preventDefault()
|
||||
return handleArrowKey(false)
|
||||
}
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
|
||||
// Enter key
|
||||
editor.registerCommand(
|
||||
KEY_ENTER_COMMAND,
|
||||
(event) => {
|
||||
if (
|
||||
isActive &&
|
||||
suggestions.length > 0 &&
|
||||
selectedIndex >= 0 &&
|
||||
selectedIndex < suggestions.length
|
||||
) {
|
||||
event?.preventDefault()
|
||||
return handleEnterKey()
|
||||
}
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
),
|
||||
|
||||
// Tab key (same as enter)
|
||||
editor.registerCommand(
|
||||
KEY_TAB_COMMAND,
|
||||
(event) => {
|
||||
if (
|
||||
isActive &&
|
||||
suggestions.length > 0 &&
|
||||
selectedIndex >= 0 &&
|
||||
selectedIndex < suggestions.length
|
||||
) {
|
||||
event.preventDefault()
|
||||
return handleEnterKey()
|
||||
}
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
),
|
||||
|
||||
// Escape key
|
||||
editor.registerCommand(
|
||||
KEY_ESCAPE_COMMAND,
|
||||
(event) => {
|
||||
if (isActive) {
|
||||
event.preventDefault()
|
||||
return handleEscapeKey()
|
||||
}
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
]
|
||||
|
||||
return () => {
|
||||
removeCommands.forEach((remove) => remove())
|
||||
}
|
||||
}, [
|
||||
editor,
|
||||
isActive,
|
||||
suggestions.length,
|
||||
selectedIndex,
|
||||
handleArrowKey,
|
||||
handleEnterKey,
|
||||
handleEscapeKey,
|
||||
])
|
||||
|
||||
return {
|
||||
handleArrowKey,
|
||||
handleEnterKey,
|
||||
handleEscapeKey,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
import { useCallback, useRef, useState, useTransition } from "react"
|
||||
|
||||
import { DEFAULT_MAX_SUGGESTIONS } from "../constants"
|
||||
import type { MentionData, MentionSearchState, MentionType } from "../types"
|
||||
import { getMentionType, shouldTriggerMention } from "../utils/triggerDetection"
|
||||
|
||||
interface UseMentionSearchOptions {
|
||||
onSearch?: (query: string, type: MentionType) => Promise<MentionData[]> | MentionData[]
|
||||
maxSuggestions?: number
|
||||
}
|
||||
|
||||
// Default search function
|
||||
const defaultSearchFn = async (): Promise<MentionData[]> => []
|
||||
|
||||
export const useMentionSearch = ({
|
||||
onSearch = defaultSearchFn,
|
||||
maxSuggestions = DEFAULT_MAX_SUGGESTIONS,
|
||||
}: UseMentionSearchOptions = {}) => {
|
||||
const [searchState, setSearchState] = useState<MentionSearchState>({
|
||||
suggestions: [],
|
||||
selectedIndex: -1,
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const onSearchRef = useRef(onSearch)
|
||||
const maxSuggestionsRef = useRef(maxSuggestions)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
// Update refs when props change to avoid stale closures
|
||||
onSearchRef.current = onSearch
|
||||
maxSuggestionsRef.current = maxSuggestions
|
||||
|
||||
const searchMentions = useCallback(
|
||||
async (query: string) => {
|
||||
// Cancel any pending search
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
|
||||
if (!shouldTriggerMention(query)) {
|
||||
setSearchState((prev) => ({
|
||||
...prev,
|
||||
suggestions: [],
|
||||
selectedIndex: -1,
|
||||
isLoading: false,
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
// Create new abort controller for this search
|
||||
const abortController = new AbortController()
|
||||
abortControllerRef.current = abortController
|
||||
|
||||
// Use transition to defer search as low-priority update
|
||||
startTransition(() => {
|
||||
setSearchState((prev) => ({ ...prev, isLoading: true }))
|
||||
})
|
||||
|
||||
try {
|
||||
const mentionType = getMentionType(query)
|
||||
const results = await onSearchRef.current(query, mentionType)
|
||||
|
||||
// Check if this search was aborted
|
||||
if (abortController.signal.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
setSearchState({
|
||||
suggestions: results.slice(0, maxSuggestionsRef.current),
|
||||
selectedIndex: results.length > 0 ? 0 : -1,
|
||||
isLoading: false,
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
// Check if this search was aborted
|
||||
if (abortController.signal.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
console.error("Error searching mentions:", error)
|
||||
startTransition(() => {
|
||||
setSearchState({
|
||||
suggestions: [],
|
||||
selectedIndex: -1,
|
||||
isLoading: false,
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
[], // Empty deps array - we use refs to avoid dependency issues
|
||||
)
|
||||
|
||||
const clearSuggestions = useCallback(() => {
|
||||
setSearchState({
|
||||
suggestions: [],
|
||||
selectedIndex: -1,
|
||||
isLoading: false,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const setSelectedIndex = useCallback((index: number) => {
|
||||
setSearchState((prev) => ({ ...prev, selectedIndex: index }))
|
||||
}, [])
|
||||
|
||||
return {
|
||||
...searchState,
|
||||
searchMentions,
|
||||
clearSuggestions,
|
||||
setSelectedIndex,
|
||||
hasResults: searchState.suggestions.length > 0,
|
||||
isLoading: searchState.isLoading || isPending,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { useMemo } from "react"
|
||||
|
||||
import { useFeedEntrySearchService } from "~/modules/ai/chat/services/feedEntrySearchService"
|
||||
|
||||
import type { MentionData, MentionType } from "../types"
|
||||
|
||||
/**
|
||||
* Hook that provides search functionality for mentions
|
||||
* Uses the shared feed/entry search service
|
||||
*/
|
||||
export const useMentionSearchService = () => {
|
||||
const { search } = useFeedEntrySearchService({
|
||||
maxRecentEntries: 50,
|
||||
})
|
||||
|
||||
// Search function that converts search results to MentionData format
|
||||
const searchMentions = useMemo(() => {
|
||||
return async (query: string, type?: MentionType): Promise<MentionData[]> => {
|
||||
const searchResults = search(query, type, 10)
|
||||
|
||||
// Convert to MentionData format
|
||||
return searchResults.map(
|
||||
(item): MentionData => ({
|
||||
id: item.id,
|
||||
name: item.title,
|
||||
type: item.type as MentionType,
|
||||
value: item.id,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}, [search])
|
||||
|
||||
return { searchMentions }
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
|
||||
import { COMMAND_PRIORITY_LOW } from "lexical"
|
||||
import { useCallback, useEffect } from "react"
|
||||
|
||||
import { MENTION_COMMAND } from "../constants"
|
||||
import type { MentionData, MentionMatch } from "../types"
|
||||
import { insertMentionNode } from "../utils/textReplacement"
|
||||
|
||||
interface UseMentionSelectionOptions {
|
||||
mentionMatch: MentionMatch | null
|
||||
onMentionInsert?: (mention: MentionData, nodeKey?: string) => void
|
||||
onSelectionComplete?: () => void
|
||||
}
|
||||
|
||||
export const useMentionSelection = ({
|
||||
mentionMatch,
|
||||
onMentionInsert,
|
||||
onSelectionComplete,
|
||||
}: UseMentionSelectionOptions) => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
const selectMention = useCallback(
|
||||
(mentionData: MentionData) => {
|
||||
if (!mentionMatch) return false
|
||||
|
||||
let result = { success: false, nodeKey: undefined as string | undefined }
|
||||
editor.update(() => {
|
||||
result = insertMentionNode(mentionData, mentionMatch)
|
||||
|
||||
if (result.success && result.nodeKey) {
|
||||
// Call onMentionInsert immediately within the same editor update
|
||||
// to ensure the node tracking happens before any mutations
|
||||
onMentionInsert?.(mentionData, result.nodeKey)
|
||||
}
|
||||
})
|
||||
|
||||
// Call onSelectionComplete after the editor update is complete
|
||||
if (result.success) {
|
||||
setTimeout(() => {
|
||||
onSelectionComplete?.()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
return result.success
|
||||
},
|
||||
[editor, mentionMatch, onMentionInsert, onSelectionComplete],
|
||||
)
|
||||
|
||||
// Register mention command
|
||||
useEffect(() => {
|
||||
const removeMentionCommand = editor.registerCommand(
|
||||
MENTION_COMMAND,
|
||||
(mentionData: MentionData) => {
|
||||
return selectMention(mentionData)
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
)
|
||||
|
||||
return removeMentionCommand
|
||||
}, [editor, selectMention])
|
||||
|
||||
return {
|
||||
selectMention,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
|
||||
import type { LexicalEditor } from "lexical"
|
||||
import { $getSelection, $isRangeSelection, $isTextNode } from "lexical"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
||||
import type { MentionMatch } from "../types"
|
||||
import { defaultTriggerFn } from "../utils/triggerDetection"
|
||||
|
||||
interface UseMentionTriggerOptions {
|
||||
triggerFn?: (text: string, editor: LexicalEditor) => MentionMatch | null
|
||||
onTrigger?: (match: MentionMatch | null) => void
|
||||
}
|
||||
|
||||
export const useMentionTrigger = ({
|
||||
triggerFn = defaultTriggerFn,
|
||||
onTrigger,
|
||||
}: UseMentionTriggerOptions = {}) => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const [mentionMatch, setMentionMatch] = useState<MentionMatch | null>(null)
|
||||
|
||||
const updateMentionMatch = useCallback(
|
||||
(match: MentionMatch | null) => {
|
||||
setMentionMatch(match)
|
||||
onTrigger?.(match)
|
||||
},
|
||||
[onTrigger],
|
||||
)
|
||||
|
||||
const clearMentionMatch = useCallback(() => {
|
||||
updateMentionMatch(null)
|
||||
}, [updateMentionMatch])
|
||||
|
||||
// Monitor text changes for mention triggers
|
||||
useEffect(() => {
|
||||
const removeUpdateListener = editor.registerUpdateListener(({ editorState }) => {
|
||||
editorState.read(() => {
|
||||
const selection = $getSelection()
|
||||
if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
|
||||
updateMentionMatch(null)
|
||||
return
|
||||
}
|
||||
|
||||
const { anchor } = selection
|
||||
const anchorNode = anchor.getNode()
|
||||
|
||||
if (!$isTextNode(anchorNode)) {
|
||||
updateMentionMatch(null)
|
||||
return
|
||||
}
|
||||
|
||||
const textContent = anchorNode.getTextContent()
|
||||
const match = triggerFn(textContent, editor)
|
||||
updateMentionMatch(match)
|
||||
})
|
||||
})
|
||||
|
||||
return removeUpdateListener
|
||||
}, [editor, triggerFn, updateMentionMatch])
|
||||
|
||||
return {
|
||||
mentionMatch,
|
||||
isActive: mentionMatch !== null,
|
||||
clearMentionMatch,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
// Public API exports
|
||||
export { MentionComponent } from "./components/MentionComponent"
|
||||
export { MentionDropdown } from "./components/MentionDropdown"
|
||||
export { $createMentionNode, $isMentionNode, MentionNode } from "./MentionNode"
|
||||
export { MentionPlugin } from "./MentionPlugin"
|
||||
|
||||
// Commands
|
||||
export { MENTION_COMMAND, MENTION_TYPEAHEAD_COMMAND } from "./constants"
|
||||
|
||||
// Types
|
||||
export type {
|
||||
MentionData,
|
||||
MentionDropdownPosition,
|
||||
MentionMatch,
|
||||
MentionPluginProps,
|
||||
MentionSearchState,
|
||||
MentionTriggerState,
|
||||
MentionType,
|
||||
} from "./types"
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
export type MentionType = "entry" | "feed"
|
||||
|
||||
export interface MentionData {
|
||||
id: string
|
||||
name: string
|
||||
type: MentionType
|
||||
value: unknown
|
||||
}
|
||||
|
||||
export interface MentionMatch {
|
||||
leadOffset: number
|
||||
matchingString: string
|
||||
replaceableString: string
|
||||
}
|
||||
|
||||
export interface MentionDropdownPosition {
|
||||
top: number
|
||||
left: number
|
||||
}
|
||||
|
||||
export interface MentionSearchState {
|
||||
suggestions: MentionData[]
|
||||
selectedIndex: number
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export interface MentionTriggerState {
|
||||
mentionMatch: MentionMatch | null
|
||||
isActive: boolean
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import type { LexicalEditor } from "lexical"
|
||||
|
||||
import type { MentionDropdownPosition } from "../types"
|
||||
|
||||
export const calculateDropdownPosition = (
|
||||
editor: LexicalEditor,
|
||||
): MentionDropdownPosition | null => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) return null
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
const rect = range.getBoundingClientRect()
|
||||
const editorElement = editor.getRootElement()
|
||||
|
||||
if (!editorElement) return null
|
||||
|
||||
const editorRect = editorElement.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
top: rect.bottom - editorRect.top + 8, // 8px offset below cursor
|
||||
left: rect.left - editorRect.left,
|
||||
}
|
||||
}
|
||||
|
||||
export const scrollSelectedItemIntoView = (
|
||||
containerRef: React.RefObject<HTMLElement>,
|
||||
selectedIndex: number,
|
||||
): void => {
|
||||
if (!containerRef.current || selectedIndex < 0) return
|
||||
|
||||
const selectedElement = containerRef.current.children[selectedIndex] as HTMLElement
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import { $createTextNode, $getSelection, $isRangeSelection, $isTextNode } from "lexical"
|
||||
|
||||
import { $createMentionNode } from "../MentionNode"
|
||||
import type { MentionData, MentionMatch } from "../types"
|
||||
|
||||
export const insertMentionNode = (
|
||||
mentionData: MentionData,
|
||||
mentionMatch: MentionMatch,
|
||||
): { success: boolean; nodeKey?: string } => {
|
||||
const selection = $getSelection()
|
||||
if (!$isRangeSelection(selection) || !selection.isCollapsed()) return { success: false }
|
||||
|
||||
const { anchor } = selection
|
||||
const anchorNode = anchor.getNode()
|
||||
|
||||
if (!$isTextNode(anchorNode)) return { success: false }
|
||||
|
||||
// Replace the mention text with the mention node
|
||||
const textContent = anchorNode.getTextContent()
|
||||
const { leadOffset, replaceableString } = mentionMatch
|
||||
|
||||
// Split the text node
|
||||
const beforeText = textContent.slice(0, leadOffset)
|
||||
const afterText = textContent.slice(leadOffset + replaceableString.length)
|
||||
|
||||
// Create new nodes
|
||||
const beforeNode = beforeText ? $createTextNode(beforeText) : null
|
||||
const mentionNode = $createMentionNode(mentionData)
|
||||
const afterNode = afterText ? $createTextNode(afterText) : null
|
||||
|
||||
// Replace the current node
|
||||
if (beforeNode) {
|
||||
anchorNode.insertBefore(beforeNode)
|
||||
}
|
||||
anchorNode.insertBefore(mentionNode)
|
||||
if (afterNode) {
|
||||
anchorNode.insertBefore(afterNode)
|
||||
}
|
||||
|
||||
// Remove the original node
|
||||
anchorNode.remove()
|
||||
|
||||
// Position cursor after the mention
|
||||
if (afterNode) {
|
||||
afterNode.select(0, 0)
|
||||
} else {
|
||||
// Create a space after the mention if there's no following text
|
||||
const spaceNode = $createTextNode(" ")
|
||||
mentionNode.insertAfter(spaceNode)
|
||||
spaceNode.select(1, 1)
|
||||
}
|
||||
|
||||
return { success: true, nodeKey: mentionNode.getKey() }
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { $getSelection, $isRangeSelection, $isTextNode } from "lexical"
|
||||
|
||||
import { MENTION_TRIGGER_PATTERN } from "../constants"
|
||||
import type { MentionMatch, MentionType } from "../types"
|
||||
|
||||
export const defaultTriggerFn = (): MentionMatch | null => {
|
||||
const selection = $getSelection()
|
||||
if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { anchor } = selection
|
||||
const { focus } = selection
|
||||
const anchorNode = anchor.getNode()
|
||||
|
||||
// Only trigger on text nodes
|
||||
if (!$isTextNode(anchorNode) || anchor.key !== focus.key || anchor.offset !== focus.offset) {
|
||||
return null
|
||||
}
|
||||
|
||||
const textContent = anchorNode.getTextContent()
|
||||
const cursorOffset = anchor.offset
|
||||
|
||||
// Look for @ symbol followed by text
|
||||
const mentionMatch = textContent.slice(0, cursorOffset).match(MENTION_TRIGGER_PATTERN)
|
||||
|
||||
if (!mentionMatch) {
|
||||
return null
|
||||
}
|
||||
|
||||
const matchingString = mentionMatch[1] || ""
|
||||
const replaceableString = matchingString
|
||||
const leadOffset = (mentionMatch.index ?? 0) + (mentionMatch[0]?.startsWith(" ") ? 1 : 0)
|
||||
|
||||
return {
|
||||
leadOffset,
|
||||
matchingString,
|
||||
replaceableString,
|
||||
}
|
||||
}
|
||||
|
||||
export const getMentionType = (query: string): MentionType => {
|
||||
// Simple heuristic - could be enhanced with more sophisticated detection
|
||||
if (query.startsWith("@#")) return "feed"
|
||||
if (query.startsWith("@+")) return "entry"
|
||||
return "feed"
|
||||
}
|
||||
|
||||
export const cleanQuery = (query: string): string => {
|
||||
return query.replace(/^@[#+]?/, "").toLowerCase()
|
||||
}
|
||||
|
||||
export const shouldTriggerMention = (query: string): boolean => {
|
||||
return query.startsWith("@") && query.length > 0
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { useMemo } from "react"
|
||||
|
||||
import type { SearchItem } from "./feedEntrySearchService"
|
||||
import { useFeedEntrySearchService } from "./feedEntrySearchService"
|
||||
|
||||
/**
|
||||
* Picker item interface for context bar compatibility
|
||||
*/
|
||||
export interface PickerItem {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that provides search functionality for context bar
|
||||
* Uses the shared feed/entry search service and converts results to PickerItem format
|
||||
*/
|
||||
export const useContextBarSearchService = () => {
|
||||
const { search, feedItems, entryItems } = useFeedEntrySearchService({
|
||||
maxRecentEntries: 50,
|
||||
fuseOptions: {
|
||||
keys: ["title", "id"],
|
||||
threshold: 0.3,
|
||||
},
|
||||
})
|
||||
|
||||
// Convert search items to picker items
|
||||
const convertToPickerItems = useMemo(() => {
|
||||
return (items: SearchItem[]): PickerItem[] => {
|
||||
return items.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
}))
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Search function for feeds
|
||||
const searchFeeds = useMemo(() => {
|
||||
return (query: string): PickerItem[] => {
|
||||
const results = search(query, "feed", 20)
|
||||
return convertToPickerItems(results)
|
||||
}
|
||||
}, [search, convertToPickerItems])
|
||||
|
||||
// Search function for entries
|
||||
const searchEntries = useMemo(() => {
|
||||
return (query: string): PickerItem[] => {
|
||||
const results = search(query, "entry", 20)
|
||||
return convertToPickerItems(results)
|
||||
}
|
||||
}, [search, convertToPickerItems])
|
||||
|
||||
// Get all feeds as picker items
|
||||
const allFeeds = useMemo(() => {
|
||||
return convertToPickerItems(feedItems)
|
||||
}, [feedItems, convertToPickerItems])
|
||||
|
||||
// Get all recent entries as picker items
|
||||
const allRecentEntries = useMemo(() => {
|
||||
return convertToPickerItems(entryItems)
|
||||
}, [entryItems, convertToPickerItems])
|
||||
|
||||
return {
|
||||
searchFeeds,
|
||||
searchEntries,
|
||||
allFeeds,
|
||||
allRecentEntries,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import { useEntryIdsByView } from "@follow/store/entry/hooks"
|
||||
import { useEntryStore } from "@follow/store/entry/store"
|
||||
import { getFeedById } from "@follow/store/feed/getter"
|
||||
import { useAllFeedSubscription } from "@follow/store/subscription/hooks"
|
||||
import type { IFuseOptions } from "fuse.js"
|
||||
import Fuse from "fuse.js"
|
||||
import { useMemo } from "react"
|
||||
|
||||
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
|
||||
/**
|
||||
* Generic search item interface
|
||||
*/
|
||||
export interface SearchItem {
|
||||
id: string
|
||||
title: string
|
||||
type: "feed" | "entry"
|
||||
}
|
||||
|
||||
/**
|
||||
* Search service options
|
||||
*/
|
||||
export interface SearchServiceOptions {
|
||||
/** Maximum number of recent entries to include */
|
||||
maxRecentEntries?: number
|
||||
/** Fuse.js search options */
|
||||
fuseOptions?: IFuseOptions<SearchItem>
|
||||
}
|
||||
|
||||
const defaultFuseOptions: IFuseOptions<SearchItem> = {
|
||||
keys: ["title", "id"],
|
||||
threshold: 0.3,
|
||||
includeScore: true,
|
||||
}
|
||||
/**
|
||||
* Hook that provides unified search functionality for feeds and entries
|
||||
* Used by both context bar and mention plugin
|
||||
*/
|
||||
export const useFeedEntrySearchService = (options: SearchServiceOptions = {}) => {
|
||||
const { maxRecentEntries = 50, fuseOptions = defaultFuseOptions } = options
|
||||
|
||||
// Get data sources
|
||||
const allSubscriptions = useAllFeedSubscription()
|
||||
const view = useRouteParamsSelector((route) => route.view)
|
||||
const recentEntryIds = useEntryIdsByView(view, false)
|
||||
const entryStore = useEntryStore((state) => state.data)
|
||||
|
||||
// Prepare feed items
|
||||
const feedItems = useMemo(() => {
|
||||
return allSubscriptions
|
||||
.filter((subscription) => subscription.feedId)
|
||||
.map((subscription) => {
|
||||
const customTitle = subscription.title
|
||||
if (!subscription.feedId) return null
|
||||
|
||||
const feed = getFeedById(subscription.feedId!)
|
||||
return {
|
||||
id: subscription.feedId!,
|
||||
title: customTitle || feed?.title || `Feed ${subscription.feedId}`,
|
||||
type: "feed" as const,
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as SearchItem[]
|
||||
}, [allSubscriptions])
|
||||
|
||||
// Prepare entry items (recent entries, limited for performance)
|
||||
const entryItems = useMemo(() => {
|
||||
if (!recentEntryIds) return []
|
||||
|
||||
return recentEntryIds
|
||||
.slice(0, maxRecentEntries)
|
||||
.map((entryId) => {
|
||||
const entry = entryStore[entryId]
|
||||
return entry
|
||||
? {
|
||||
id: entryId,
|
||||
title: entry.title || "Untitled",
|
||||
type: "entry" as const,
|
||||
}
|
||||
: null
|
||||
})
|
||||
.filter(Boolean) as SearchItem[]
|
||||
}, [recentEntryIds, entryStore, maxRecentEntries])
|
||||
|
||||
// Combine all search items
|
||||
const allItems = useMemo(() => {
|
||||
return [...feedItems, ...entryItems]
|
||||
}, [feedItems, entryItems])
|
||||
|
||||
// Create Fuse instance for fuzzy search
|
||||
const fuse = useMemo(() => {
|
||||
return new Fuse(allItems, fuseOptions)
|
||||
}, [allItems, JSON.stringify(fuseOptions)])
|
||||
|
||||
// Search function
|
||||
const search = useMemo(() => {
|
||||
return (query: string, type?: "feed" | "entry", maxResults = 10): SearchItem[] => {
|
||||
if (!query.trim()) {
|
||||
// If no query, return recent items of the specified type
|
||||
const filteredItems = allItems.filter((item) => !type || item.type === type)
|
||||
return filteredItems.slice(0, maxResults)
|
||||
}
|
||||
|
||||
// Perform fuzzy search
|
||||
const fuseResults = fuse.search(query)
|
||||
return fuseResults
|
||||
.map((result) => result.item)
|
||||
.filter((item) => !type || item.type === type)
|
||||
.slice(0, maxResults)
|
||||
}
|
||||
}, [allItems, fuse])
|
||||
|
||||
return {
|
||||
search,
|
||||
feedItems,
|
||||
entryItems,
|
||||
allItems,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M11.2 2.04a9.993 9.993 0 0 0-9.038 8.252c-.643 3.731.879 7.523 3.918 9.754a10.003 10.003 0 0 0 7.88 1.75c1.03-.202 2.343-.688 2.697-.999.527-.463.416-1.285-.217-1.61-.317-.162-.613-.141-1.1.08-2.211 1.004-4.674.975-6.84-.081-1.595-.777-2.929-2.109-3.679-3.672-.818-1.706-1.031-3.587-.604-5.334.593-2.425 2.154-4.353 4.386-5.415 1.779-.847 3.872-.998 5.862-.423a7.728 7.728 0 0 1 4.954 4.356c.4.927.578 1.716.609 2.702.057 1.85-.48 3.194-1.477 3.694-.36.18-.75.282-.946.247-.334-.061-.576-.347-.627-.743-.014-.103.087-1.277.239-2.785.145-1.433.263-2.707.263-2.831a.855.855 0 0 0-.241-.637.994.994 0 0 0-.681-.335.96.96 0 0 0-.8.341l-.136.161-.241-.223c-.985-.913-2.492-1.401-3.861-1.25-2.392.263-4.231 2.085-4.48 4.439-.22 2.082.882 4.06 2.781 4.995.343.168.826.344 1.069.388.095.018.15.05.15.088 0 .053.107.058.83.041.912-.023 1.275-.077 1.843-.276a4.736 4.736 0 0 0 1.224-.641c.175-.126.333-.219.351-.208a.82.82 0 0 1 .118.188c.144.283.69.789 1.054.976.687.353 1.585.388 2.427.096 1.644-.571 2.676-1.956 3.042-4.085.118-.681.126-2.082.017-2.74-.358-2.157-1.291-3.978-2.798-5.458-1.536-1.51-3.492-2.453-5.688-2.742a13.715 13.715 0 0 0-2.26-.06m1.64 7.073c.368.112.542.192.845.388 1.032.666 1.563 1.905 1.321 3.076a2.993 2.993 0 0 1-1.626 2.105c-.484.238-.754.297-1.36.297-.432 0-.571-.016-.819-.094-1.536-.478-2.437-1.929-2.145-3.451.115-.594.351-1.04.798-1.505a3.04 3.04 0 0 1 1.547-.869c.355-.081 1.09-.053 1.439.053" fill="#10161F" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -23,6 +23,9 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@essentials/request-timeout": "1.3.0",
|
||||
"@floating-ui/core": "*",
|
||||
"@floating-ui/react": "*",
|
||||
"@floating-ui/react-dom": "*",
|
||||
"@follow/hooks": "workspace:*",
|
||||
"@follow/models": "workspace:*",
|
||||
"@follow/types": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -18,12 +18,12 @@ import { useImperativeHandle, useRef, useState } from "react"
|
|||
import { LexicalRichEditorNodes } from "./nodes"
|
||||
import { KeyboardPlugin } from "./plugins"
|
||||
import { defaultLexicalTheme } from "./theme"
|
||||
import type { LexicalRichEditorProps, LexicalRichEditorRef } from "./types"
|
||||
import type { BuiltInPlugins, LexicalRichEditorProps, LexicalRichEditorRef } from "./types"
|
||||
|
||||
function onError(error: Error) {
|
||||
console.error("Lexical Editor Error:", error)
|
||||
}
|
||||
const defaultEnabledPlugins = {
|
||||
const defaultEnabledPlugins: BuiltInPlugins = {
|
||||
history: true,
|
||||
markdown: true,
|
||||
list: true,
|
||||
|
|
@ -42,15 +42,22 @@ export const LexicalRichEditor = ({
|
|||
theme = defaultLexicalTheme,
|
||||
enabledPlugins = defaultEnabledPlugins,
|
||||
initalEditorState,
|
||||
plugins,
|
||||
}: LexicalRichEditorProps & { ref?: React.RefObject<LexicalRichEditorRef | null> }) => {
|
||||
const editorRef = useRef<LexicalEditor | null>(null)
|
||||
const [isEmpty, setIsEmpty] = useState(true)
|
||||
|
||||
// Collect nodes from plugins
|
||||
const pluginNodes = plugins?.flatMap((plugin) => plugin.nodes || []) || []
|
||||
|
||||
// Merge base nodes with custom nodes and plugin nodes
|
||||
const allNodes = [...LexicalRichEditorNodes, ...pluginNodes]
|
||||
|
||||
const initialConfig: InitialConfigType = {
|
||||
namespace,
|
||||
theme,
|
||||
onError,
|
||||
nodes: LexicalRichEditorNodes,
|
||||
nodes: allNodes,
|
||||
editorState: initalEditorState,
|
||||
}
|
||||
|
||||
|
|
@ -88,13 +95,13 @@ export const LexicalRichEditor = ({
|
|||
contentEditable={
|
||||
<ContentEditable
|
||||
className={cn(
|
||||
"scrollbar-none text-text placeholder:text-text-secondary",
|
||||
"max-h-40 min-h-14 w-full resize-none bg-transparent px-5 py-3.5 pr-14",
|
||||
"scrollbar-none text-text placeholder:text-text-secondary cursor-text",
|
||||
"max-h-40 min-h-14 w-full resize-none bg-transparent",
|
||||
"text-sm !outline-none transition-all duration-200 focus:outline-none",
|
||||
)}
|
||||
aria-placeholder={placeholder}
|
||||
placeholder={
|
||||
<div className="text-text-secondary pointer-events-none absolute left-5 top-3.5 text-sm">
|
||||
<div className="text-text-secondary pointer-events-none absolute left-0 top-0 text-sm">
|
||||
{placeholder}
|
||||
</div>
|
||||
}
|
||||
|
|
@ -109,6 +116,10 @@ export const LexicalRichEditor = ({
|
|||
{enabledPlugins.list && <ListPlugin />}
|
||||
{enabledPlugins.link && <LinkPlugin />}
|
||||
|
||||
{plugins?.map((Plugin) => (
|
||||
<Plugin key={Plugin.id} />
|
||||
))}
|
||||
|
||||
<KeyboardPlugin onKeyDown={onKeyDown} />
|
||||
{autoFocus && enabledPlugins.autoFocus && <AutoFocusPlugin />}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,4 +3,4 @@ export { LexicalRichEditor } from "./LexicalRichEditor"
|
|||
export { LexicalRichEditorNodes } from "./nodes"
|
||||
export { KeyboardPlugin } from "./plugins"
|
||||
export { defaultLexicalTheme } from "./theme"
|
||||
export type { LexicalRichEditorProps, LexicalRichEditorRef } from "./types"
|
||||
export type * from "./types"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import { MarkNode } from "@lexical/mark"
|
|||
import { HeadingNode, QuoteNode } from "@lexical/rich-text"
|
||||
import { ParagraphNode, TextNode } from "lexical"
|
||||
|
||||
// Mention functionality moved to desktop app, import should be handled there
|
||||
// import { MentionNode } from "./plugins/mention"
|
||||
|
||||
export const LexicalRichEditorNodes = [
|
||||
// Core nodes
|
||||
ParagraphNode,
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
|
||||
import { useEffect } from "react"
|
||||
|
||||
interface KeyboardPluginProps {
|
||||
onKeyDown?: (event: KeyboardEvent) => boolean
|
||||
}
|
||||
|
||||
export function KeyboardPlugin({ onKeyDown }: KeyboardPluginProps) {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (!onKeyDown) return
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
onKeyDown(event)
|
||||
}
|
||||
|
||||
return editor.registerRootListener((rootElement, prevRootElement) => {
|
||||
if (prevRootElement !== null) {
|
||||
prevRootElement.removeEventListener("keydown", handleKeyDown)
|
||||
}
|
||||
if (rootElement !== null) {
|
||||
rootElement.addEventListener("keydown", handleKeyDown)
|
||||
}
|
||||
})
|
||||
}, [editor, onKeyDown])
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
export { KeyboardPlugin } from "./KeyboardPlugin"
|
||||
export { KeyboardPlugin } from "./keyboard"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
|
||||
import { COMMAND_PRIORITY_LOW, KEY_ENTER_COMMAND } from "lexical"
|
||||
import { useEffect } from "react"
|
||||
|
||||
interface KeyboardPluginProps {
|
||||
onKeyDown?: (event: KeyboardEvent) => boolean
|
||||
}
|
||||
|
||||
export function KeyboardPlugin({ onKeyDown }: KeyboardPluginProps) {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (!onKeyDown) return
|
||||
|
||||
// Register a low-priority command that will only execute if no higher-priority commands handle the event
|
||||
const removeEnterCommand = editor.registerCommand(
|
||||
KEY_ENTER_COMMAND,
|
||||
(event) => {
|
||||
// This will only be called if no higher-priority commands handled the Enter key
|
||||
if (!event) return false
|
||||
const handled = onKeyDown(event)
|
||||
return handled
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
)
|
||||
|
||||
// For other keys, use DOM event listener
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Skip Enter key as it's handled by the command system
|
||||
if (event.key === "Enter") return
|
||||
|
||||
const handled = onKeyDown(event)
|
||||
if (handled) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
const removeRootListener = editor.registerRootListener((rootElement, prevRootElement) => {
|
||||
if (prevRootElement !== null) {
|
||||
prevRootElement.removeEventListener("keydown", handleKeyDown)
|
||||
}
|
||||
if (rootElement !== null) {
|
||||
rootElement.addEventListener("keydown", handleKeyDown)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeEnterCommand()
|
||||
removeRootListener()
|
||||
}
|
||||
}, [editor, onKeyDown])
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { EditorState, LexicalEditor } from "lexical"
|
||||
import type { EditorState, Klass, LexicalEditor, LexicalNode } from "lexical"
|
||||
|
||||
export interface LexicalRichEditorRef {
|
||||
getEditor: () => LexicalEditor
|
||||
|
|
@ -7,6 +7,13 @@ export interface LexicalRichEditorRef {
|
|||
isEmpty: () => boolean
|
||||
}
|
||||
|
||||
export interface BuiltInPlugins {
|
||||
history?: boolean
|
||||
markdown?: boolean
|
||||
list?: boolean
|
||||
link?: boolean
|
||||
autoFocus?: boolean
|
||||
}
|
||||
export interface LexicalRichEditorProps {
|
||||
placeholder?: string
|
||||
className?: string
|
||||
|
|
@ -15,12 +22,11 @@ export interface LexicalRichEditorProps {
|
|||
autoFocus?: boolean
|
||||
namespace?: string
|
||||
theme?: any
|
||||
enabledPlugins?: {
|
||||
history?: boolean
|
||||
markdown?: boolean
|
||||
list?: boolean
|
||||
link?: boolean
|
||||
autoFocus?: boolean
|
||||
}
|
||||
enabledPlugins?: BuiltInPlugins
|
||||
initalEditorState?: EditorState
|
||||
plugins?: LexicalPluginFC[]
|
||||
}
|
||||
export type LexicalPluginFC<T = unknown> = React.FC<T> & {
|
||||
id: string
|
||||
nodes?: ReadonlyArray<Klass<LexicalNode>>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export enum TrackerMapper {
|
|||
Register = 3000,
|
||||
OnBoarding = 3001,
|
||||
Subscribe = 3002,
|
||||
EntryRead = 3003,
|
||||
EntryAction = 3004,
|
||||
ViewAction = 3005,
|
||||
|
||||
// AI
|
||||
AIChatMessageSent = 4000,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,16 +114,8 @@ export class TrackerPoints {
|
|||
this.track(TrackerMapper.Subscribe, props)
|
||||
}
|
||||
|
||||
entryRead(props: { entryId: string }) {
|
||||
this.track(TrackerMapper.EntryRead, props)
|
||||
}
|
||||
|
||||
entryAction(props: { entryId: string; action: string }) {
|
||||
this.track(TrackerMapper.EntryAction, props)
|
||||
}
|
||||
|
||||
viewAction(props: { view: string; action: string }) {
|
||||
this.track(TrackerMapper.ViewAction, props)
|
||||
aiChatMessageSent() {
|
||||
this.track(TrackerMapper.AIChatMessageSent)
|
||||
}
|
||||
|
||||
private track(code: TrackerMapper, properties?: Record<string, unknown>) {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ overrides:
|
|||
react-dom: 19.0.0
|
||||
react-native-ios-context-menu: 3.1.1
|
||||
react-native-ios-utilities: 5.1.5
|
||||
'@floating-ui/core': 1.7.2
|
||||
'@floating-ui/dom': 1.7.2
|
||||
'@floating-ui/react-dom': 2.1.4
|
||||
'@floating-ui/react': 0.27.14
|
||||
|
||||
patchedDependencies:
|
||||
'@microflash/remark-callout-directives':
|
||||
|
|
@ -1444,6 +1448,15 @@ importers:
|
|||
'@essentials/request-timeout':
|
||||
specifier: 1.3.0
|
||||
version: 1.3.0
|
||||
'@floating-ui/core':
|
||||
specifier: 1.7.2
|
||||
version: 1.7.2
|
||||
'@floating-ui/react':
|
||||
specifier: 0.27.14
|
||||
version: 0.27.14(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
'@floating-ui/react-dom':
|
||||
specifier: 2.1.4
|
||||
version: 2.1.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
'@follow/hooks':
|
||||
specifier: workspace:*
|
||||
version: link:../hooks
|
||||
|
|
@ -4051,36 +4064,18 @@ packages:
|
|||
'@firebase/webchannel-wrapper@1.0.3':
|
||||
resolution: {integrity: sha512-2xCRM9q9FlzGZCdgDMJwc0gyUkWFtkosy7Xxr6sFgQwn+wMNIWd7xIvYNauU1r64B5L5rsGKy/n9TKJ0aAFeqQ==}
|
||||
|
||||
'@floating-ui/core@1.7.0':
|
||||
resolution: {integrity: sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA==}
|
||||
|
||||
'@floating-ui/core@1.7.2':
|
||||
resolution: {integrity: sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==}
|
||||
|
||||
'@floating-ui/dom@1.7.0':
|
||||
resolution: {integrity: sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg==}
|
||||
|
||||
'@floating-ui/dom@1.7.2':
|
||||
resolution: {integrity: sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==}
|
||||
|
||||
'@floating-ui/react-dom@2.1.2':
|
||||
resolution: {integrity: sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==}
|
||||
peerDependencies:
|
||||
react: 19.0.0
|
||||
react-dom: 19.0.0
|
||||
|
||||
'@floating-ui/react-dom@2.1.4':
|
||||
resolution: {integrity: sha512-JbbpPhp38UmXDDAu60RJmbeme37Jbgsm7NrHGgzYYFKmblzRUh6Pa641dII6LsjwF4XlScDrde2UAzDo/b9KPw==}
|
||||
peerDependencies:
|
||||
react: 19.0.0
|
||||
react-dom: 19.0.0
|
||||
|
||||
'@floating-ui/react@0.26.28':
|
||||
resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==}
|
||||
peerDependencies:
|
||||
react: 19.0.0
|
||||
react-dom: 19.0.0
|
||||
|
||||
'@floating-ui/react@0.27.14':
|
||||
resolution: {integrity: sha512-aSf9JXfyXpRQWMbtuW+CJQrnhzHu4Hg1Th9AkvR1o+wSW/vCUVMrtgXaRY5ToV5Fh5w3I7lXJdvlKVvYrQrppw==}
|
||||
peerDependencies:
|
||||
|
|
@ -4090,9 +4085,6 @@ packages:
|
|||
'@floating-ui/utils@0.2.10':
|
||||
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
|
||||
|
||||
'@floating-ui/utils@0.2.9':
|
||||
resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==}
|
||||
|
||||
'@follow-app/client-sdk@0.3.25':
|
||||
resolution: {integrity: sha512-+x9x3bvcCedkqH2ypw1ae2zYySzT70FniC9LH8/TdqpYKIUzQZaMSZxGNIA9KaAQT1EtvL9e7lO07/zpQiEPSw==}
|
||||
|
||||
|
|
@ -19285,44 +19277,21 @@ snapshots:
|
|||
|
||||
'@firebase/webchannel-wrapper@1.0.3': {}
|
||||
|
||||
'@floating-ui/core@1.7.0':
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.10
|
||||
|
||||
'@floating-ui/core@1.7.2':
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.10
|
||||
|
||||
'@floating-ui/dom@1.7.0':
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.7.0
|
||||
'@floating-ui/utils': 0.2.10
|
||||
|
||||
'@floating-ui/dom@1.7.2':
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.7.2
|
||||
'@floating-ui/utils': 0.2.10
|
||||
|
||||
'@floating-ui/react-dom@2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.0
|
||||
react: 19.0.0
|
||||
react-dom: 19.0.0(react@19.0.0)
|
||||
|
||||
'@floating-ui/react-dom@2.1.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.2
|
||||
react: 19.0.0
|
||||
react-dom: 19.0.0(react@19.0.0)
|
||||
|
||||
'@floating-ui/react@0.26.28(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
|
||||
dependencies:
|
||||
'@floating-ui/react-dom': 2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
'@floating-ui/utils': 0.2.9
|
||||
react: 19.0.0
|
||||
react-dom: 19.0.0(react@19.0.0)
|
||||
tabbable: 6.2.0
|
||||
|
||||
'@floating-ui/react@0.27.14(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
|
||||
dependencies:
|
||||
'@floating-ui/react-dom': 2.1.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
|
|
@ -19333,8 +19302,6 @@ snapshots:
|
|||
|
||||
'@floating-ui/utils@0.2.10': {}
|
||||
|
||||
'@floating-ui/utils@0.2.9': {}
|
||||
|
||||
'@follow-app/client-sdk@0.3.25(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3)':
|
||||
dependencies:
|
||||
'@folo-services/constants': 0.1.15
|
||||
|
|
@ -19582,7 +19549,7 @@ snapshots:
|
|||
|
||||
'@headlessui/react@2.2.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
|
||||
dependencies:
|
||||
'@floating-ui/react': 0.26.28(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
'@floating-ui/react': 0.27.14(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
'@react-aria/focus': 3.20.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
'@react-aria/interactions': 3.25.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
'@tanstack/react-virtual': 3.13.12(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
|
|
@ -21027,7 +20994,7 @@ snapshots:
|
|||
|
||||
'@radix-ui/react-popper@1.2.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
|
||||
dependencies:
|
||||
'@floating-ui/react-dom': 2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
'@floating-ui/react-dom': 2.1.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
'@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.0.0)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.0.0)
|
||||
|
|
|
|||
|
|
@ -51,6 +51,11 @@ overrides:
|
|||
react-native-ios-context-menu: "3.1.1"
|
||||
react-native-ios-utilities: "5.1.5"
|
||||
|
||||
"@floating-ui/core": "1.7.2"
|
||||
"@floating-ui/dom": "1.7.2"
|
||||
"@floating-ui/react-dom": "2.1.4"
|
||||
"@floating-ui/react": "0.27.14"
|
||||
|
||||
catalog:
|
||||
typescript: "5.8.3"
|
||||
"@follow-app/client-sdk": "0.3.25"
|
||||
|
|
|
|||
Loading…
Reference in New Issue