mirror of
https://github.com/EthanMarti/infio-copilot.git
synced 2026-05-08 16:10:09 +00:00
init
This commit is contained in:
90
src/components/chat-view/AssistantMessageActions.tsx
Normal file
90
src/components/chat-view/AssistantMessageActions.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { Check, CopyIcon } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { ChatAssistantMessage } from '../../types/chat'
|
||||
import { calculateLLMCost } from '../../utils/price-calculator'
|
||||
|
||||
import LLMResponseInfoPopover from './LLMResponseInfoPopover'
|
||||
|
||||
function CopyButton({ message }: { message: ChatAssistantMessage }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(message.content)
|
||||
setCopied(true)
|
||||
setTimeout(() => {
|
||||
setCopied(false)
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip.Provider delayDuration={0}>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button>
|
||||
{copied ? (
|
||||
<Check
|
||||
size={12}
|
||||
className="infio-chat-message-actions-icon--copied"
|
||||
/>
|
||||
) : (
|
||||
<CopyIcon onClick={handleCopy} size={12} />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="infio-tooltip-content">
|
||||
Copy message
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function LLMResponesInfoButton({ message }: { message: ChatAssistantMessage }) {
|
||||
const cost = useMemo<number | null>(() => {
|
||||
if (!message.metadata?.model || !message.metadata?.usage) {
|
||||
return 0
|
||||
}
|
||||
return calculateLLMCost({
|
||||
model: message.metadata.model,
|
||||
usage: message.metadata.usage,
|
||||
})
|
||||
}, [message])
|
||||
|
||||
return (
|
||||
<Tooltip.Provider delayDuration={0}>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div>
|
||||
<LLMResponseInfoPopover
|
||||
usage={message.metadata?.usage}
|
||||
estimatedPrice={cost}
|
||||
model={message.metadata?.model?.name}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="infio-tooltip-content">
|
||||
View details
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AssistantMessageActions({
|
||||
message,
|
||||
}: {
|
||||
message: ChatAssistantMessage
|
||||
}) {
|
||||
return (
|
||||
<div className="infio-chat-message-actions">
|
||||
<LLMResponesInfoButton message={message} />
|
||||
<CopyButton message={message} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
737
src/components/chat-view/Chat.tsx
Normal file
737
src/components/chat-view/Chat.tsx
Normal file
@@ -0,0 +1,737 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { CircleStop, History, Plus } from 'lucide-react'
|
||||
import { App, Notice } from 'obsidian'
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import { ApplyViewState } from '../../ApplyView'
|
||||
import { APPLY_VIEW_TYPE } from '../../constants'
|
||||
import { useApp } from '../../contexts/AppContext'
|
||||
import { useLLM } from '../../contexts/LLMContext'
|
||||
import { useRAG } from '../../contexts/RAGContext'
|
||||
import { useSettings } from '../../contexts/SettingsContext'
|
||||
import {
|
||||
LLMAPIKeyInvalidException,
|
||||
LLMAPIKeyNotSetException,
|
||||
LLMBaseUrlNotSetException,
|
||||
LLMModelNotSetException,
|
||||
} from '../../core/llm/exception'
|
||||
import { useChatHistory } from '../../hooks/use-chat-history'
|
||||
import { ChatMessage, ChatUserMessage } from '../../types/chat'
|
||||
import {
|
||||
MentionableBlock,
|
||||
MentionableBlockData,
|
||||
MentionableCurrentFile,
|
||||
} from '../../types/mentionable'
|
||||
import { manualApplyChangesToFile } from '../../utils/apply'
|
||||
import {
|
||||
getMentionableKey,
|
||||
serializeMentionable,
|
||||
} from '../../utils/mentionable'
|
||||
import { readTFileContent } from '../../utils/obsidian'
|
||||
import { openSettingsModalWithError } from '../../utils/open-settings-modal'
|
||||
import { PromptGenerator } from '../../utils/prompt-generator'
|
||||
|
||||
import AssistantMessageActions from './AssistantMessageActions'
|
||||
import ChatUserInput, { ChatUserInputRef } from './chat-input/ChatUserInput'
|
||||
import { editorStateToPlainText } from './chat-input/utils/editor-state-to-plain-text'
|
||||
import { ChatListDropdown } from './ChatListDropdown'
|
||||
import QueryProgress, { QueryProgressState } from './QueryProgress'
|
||||
import ReactMarkdown from './ReactMarkdown'
|
||||
import ShortcutInfo from './ShortcutInfo'
|
||||
import SimilaritySearchResults from './SimilaritySearchResults'
|
||||
// Add an empty line here
|
||||
const getNewInputMessage = (app: App): ChatUserMessage => {
|
||||
return {
|
||||
role: 'user',
|
||||
content: null,
|
||||
promptContent: null,
|
||||
id: uuidv4(),
|
||||
mentionables: [
|
||||
{
|
||||
type: 'current-file',
|
||||
file: app.workspace.getActiveFile(),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export type ChatRef = {
|
||||
openNewChat: (selectedBlock?: MentionableBlockData) => void
|
||||
addSelectionToChat: (selectedBlock: MentionableBlockData) => void
|
||||
focusMessage: () => void
|
||||
}
|
||||
|
||||
export type ChatProps = {
|
||||
selectedBlock?: MentionableBlockData
|
||||
}
|
||||
|
||||
const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
|
||||
const app = useApp()
|
||||
const { settings } = useSettings()
|
||||
const { getRAGEngine } = useRAG()
|
||||
|
||||
const {
|
||||
createOrUpdateConversation,
|
||||
deleteConversation,
|
||||
getChatMessagesById,
|
||||
updateConversationTitle,
|
||||
chatList,
|
||||
} = useChatHistory()
|
||||
const { streamResponse, chatModel } = useLLM()
|
||||
|
||||
const promptGenerator = useMemo(() => {
|
||||
return new PromptGenerator(getRAGEngine, app, settings)
|
||||
}, [getRAGEngine, app, settings])
|
||||
|
||||
const [inputMessage, setInputMessage] = useState<ChatUserMessage>(() => {
|
||||
const newMessage = getNewInputMessage(app)
|
||||
if (props.selectedBlock) {
|
||||
newMessage.mentionables = [
|
||||
...newMessage.mentionables,
|
||||
{
|
||||
type: 'block',
|
||||
...props.selectedBlock,
|
||||
},
|
||||
]
|
||||
}
|
||||
return newMessage
|
||||
})
|
||||
const [addedBlockKey, setAddedBlockKey] = useState<string | null>(
|
||||
props.selectedBlock
|
||||
? getMentionableKey(
|
||||
serializeMentionable({
|
||||
type: 'block',
|
||||
...props.selectedBlock,
|
||||
}),
|
||||
)
|
||||
: null,
|
||||
)
|
||||
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([])
|
||||
const [focusedMessageId, setFocusedMessageId] = useState<string | null>(null)
|
||||
const [currentConversationId, setCurrentConversationId] =
|
||||
useState<string>(uuidv4())
|
||||
const [queryProgress, setQueryProgress] = useState<QueryProgressState>({
|
||||
type: 'idle',
|
||||
})
|
||||
|
||||
const preventAutoScrollRef = useRef(false)
|
||||
const lastProgrammaticScrollRef = useRef<number>(0)
|
||||
const activeStreamAbortControllersRef = useRef<AbortController[]>([])
|
||||
const chatUserInputRefs = useRef<Map<string, ChatUserInputRef>>(new Map())
|
||||
const chatMessagesRef = useRef<HTMLDivElement>(null)
|
||||
const registerChatUserInputRef = (
|
||||
id: string,
|
||||
ref: ChatUserInputRef | null,
|
||||
) => {
|
||||
if (ref) {
|
||||
chatUserInputRefs.current.set(id, ref)
|
||||
} else {
|
||||
chatUserInputRefs.current.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const scrollContainer = chatMessagesRef.current
|
||||
if (!scrollContainer) return
|
||||
|
||||
const handleScroll = () => {
|
||||
// If the scroll event happened very close to our programmatic scroll, ignore it
|
||||
if (Date.now() - lastProgrammaticScrollRef.current < 50) {
|
||||
return
|
||||
}
|
||||
|
||||
preventAutoScrollRef.current =
|
||||
scrollContainer.scrollHeight -
|
||||
scrollContainer.scrollTop -
|
||||
scrollContainer.clientHeight >
|
||||
20
|
||||
}
|
||||
|
||||
scrollContainer.addEventListener('scroll', handleScroll)
|
||||
return () => scrollContainer.removeEventListener('scroll', handleScroll)
|
||||
}, [chatMessages])
|
||||
|
||||
const handleScrollToBottom = () => {
|
||||
if (chatMessagesRef.current) {
|
||||
const scrollContainer = chatMessagesRef.current
|
||||
if (scrollContainer.scrollTop !== scrollContainer.scrollHeight) {
|
||||
lastProgrammaticScrollRef.current = Date.now()
|
||||
scrollContainer.scrollTop = scrollContainer.scrollHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const abortActiveStreams = () => {
|
||||
for (const abortController of activeStreamAbortControllersRef.current) {
|
||||
abortController.abort()
|
||||
}
|
||||
activeStreamAbortControllersRef.current = []
|
||||
}
|
||||
|
||||
const handleLoadConversation = async (conversationId: string) => {
|
||||
try {
|
||||
abortActiveStreams()
|
||||
const conversation = await getChatMessagesById(conversationId)
|
||||
if (!conversation) {
|
||||
throw new Error('Conversation not found')
|
||||
}
|
||||
setCurrentConversationId(conversationId)
|
||||
setChatMessages(conversation)
|
||||
const newInputMessage = getNewInputMessage(app)
|
||||
setInputMessage(newInputMessage)
|
||||
setFocusedMessageId(newInputMessage.id)
|
||||
setQueryProgress({
|
||||
type: 'idle',
|
||||
})
|
||||
} catch (error) {
|
||||
new Notice('Failed to load conversation')
|
||||
console.error('Failed to load conversation', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewChat = (selectedBlock?: MentionableBlockData) => {
|
||||
setCurrentConversationId(uuidv4())
|
||||
setChatMessages([])
|
||||
const newInputMessage = getNewInputMessage(app)
|
||||
if (selectedBlock) {
|
||||
const mentionableBlock: MentionableBlock = {
|
||||
type: 'block',
|
||||
...selectedBlock,
|
||||
}
|
||||
newInputMessage.mentionables = [
|
||||
...newInputMessage.mentionables,
|
||||
mentionableBlock,
|
||||
]
|
||||
setAddedBlockKey(
|
||||
getMentionableKey(serializeMentionable(mentionableBlock)),
|
||||
)
|
||||
}
|
||||
setInputMessage(newInputMessage)
|
||||
setFocusedMessageId(newInputMessage.id)
|
||||
setQueryProgress({
|
||||
type: 'idle',
|
||||
})
|
||||
abortActiveStreams()
|
||||
}
|
||||
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
newChatHistory,
|
||||
useVaultSearch,
|
||||
}: {
|
||||
newChatHistory: ChatMessage[]
|
||||
useVaultSearch?: boolean
|
||||
}) => {
|
||||
abortActiveStreams()
|
||||
setQueryProgress({
|
||||
type: 'idle',
|
||||
})
|
||||
|
||||
const responseMessageId = uuidv4()
|
||||
setChatMessages([
|
||||
...newChatHistory,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
id: responseMessageId,
|
||||
metadata: {
|
||||
usage: undefined,
|
||||
model: undefined,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
try {
|
||||
const abortController = new AbortController()
|
||||
activeStreamAbortControllersRef.current.push(abortController)
|
||||
|
||||
const { requestMessages, compiledMessages } =
|
||||
await promptGenerator.generateRequestMessages({
|
||||
messages: newChatHistory,
|
||||
useVaultSearch,
|
||||
onQueryProgressChange: setQueryProgress,
|
||||
})
|
||||
setQueryProgress({
|
||||
type: 'idle',
|
||||
})
|
||||
|
||||
setChatMessages([
|
||||
...compiledMessages,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
id: responseMessageId,
|
||||
metadata: {
|
||||
usage: undefined,
|
||||
model: undefined,
|
||||
},
|
||||
},
|
||||
])
|
||||
const stream = await streamResponse(
|
||||
chatModel,
|
||||
{
|
||||
model: chatModel.name,
|
||||
messages: requestMessages,
|
||||
stream: true,
|
||||
},
|
||||
{
|
||||
signal: abortController.signal,
|
||||
},
|
||||
)
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const content = chunk.choices[0]?.delta?.content ?? ''
|
||||
setChatMessages((prevChatHistory) =>
|
||||
prevChatHistory.map((message) =>
|
||||
message.role === 'assistant' && message.id === responseMessageId
|
||||
? {
|
||||
...message,
|
||||
content: message.content + content,
|
||||
metadata: {
|
||||
...message.metadata,
|
||||
usage: chunk.usage ?? message.metadata?.usage, // Keep existing usage if chunk has no usage data
|
||||
model: chatModel,
|
||||
},
|
||||
}
|
||||
: message,
|
||||
),
|
||||
)
|
||||
if (!preventAutoScrollRef.current) {
|
||||
handleScrollToBottom()
|
||||
}
|
||||
}
|
||||
// for debugging
|
||||
setChatMessages((prevChatHistory) => {
|
||||
const lastMessage = prevChatHistory[prevChatHistory.length - 1];
|
||||
console.log("Last complete message:", lastMessage?.content);
|
||||
return prevChatHistory;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
return
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setQueryProgress({
|
||||
type: 'idle',
|
||||
})
|
||||
if (
|
||||
error instanceof LLMAPIKeyNotSetException ||
|
||||
error instanceof LLMAPIKeyInvalidException ||
|
||||
error instanceof LLMBaseUrlNotSetException ||
|
||||
error instanceof LLMModelNotSetException
|
||||
) {
|
||||
openSettingsModalWithError(app, error.message)
|
||||
} else {
|
||||
new Notice(error.message)
|
||||
console.error('Failed to generate response', error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (
|
||||
newChatHistory: ChatMessage[],
|
||||
useVaultSearch?: boolean,
|
||||
) => {
|
||||
submitMutation.mutate({ newChatHistory, useVaultSearch })
|
||||
}
|
||||
|
||||
const applyMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
blockInfo,
|
||||
}: {
|
||||
blockInfo: {
|
||||
content: string
|
||||
filename?: string
|
||||
startLine?: number
|
||||
endLine?: number
|
||||
}
|
||||
}) => {
|
||||
const activeFile = app.workspace.getActiveFile()
|
||||
if (!activeFile) {
|
||||
throw new Error(
|
||||
'No file is currently open to apply changes. Please open a file and try again.',
|
||||
)
|
||||
}
|
||||
const activeFileContent = await readTFileContent(activeFile, app.vault)
|
||||
|
||||
const updatedFileContent = await manualApplyChangesToFile(
|
||||
blockInfo.content,
|
||||
activeFile,
|
||||
activeFileContent,
|
||||
blockInfo.startLine,
|
||||
blockInfo.endLine
|
||||
)
|
||||
if (!updatedFileContent) {
|
||||
throw new Error('Failed to apply changes')
|
||||
}
|
||||
|
||||
await app.workspace.getLeaf(true).setViewState({
|
||||
type: APPLY_VIEW_TYPE,
|
||||
active: true,
|
||||
state: {
|
||||
file: activeFile,
|
||||
originalContent: activeFileContent,
|
||||
newContent: updatedFileContent,
|
||||
} satisfies ApplyViewState,
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
if (
|
||||
error instanceof LLMAPIKeyNotSetException ||
|
||||
error instanceof LLMAPIKeyInvalidException ||
|
||||
error instanceof LLMBaseUrlNotSetException ||
|
||||
error instanceof LLMModelNotSetException
|
||||
) {
|
||||
openSettingsModalWithError(app, error.message)
|
||||
} else {
|
||||
new Notice(error.message)
|
||||
console.error('Failed to apply changes', error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const handleApply = useCallback(
|
||||
(blockInfo: {
|
||||
content: string
|
||||
filename?: string
|
||||
startLine?: number
|
||||
endLine?: number
|
||||
}) => {
|
||||
applyMutation.mutate({ blockInfo })
|
||||
},
|
||||
[applyMutation],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setFocusedMessageId(inputMessage.id)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
//
|
||||
useEffect(() => {
|
||||
const updateConversationAsync = async () => {
|
||||
try {
|
||||
if (chatMessages.length > 0) {
|
||||
createOrUpdateConversation(currentConversationId, chatMessages)
|
||||
}
|
||||
} catch (error) {
|
||||
new Notice('Failed to save chat history')
|
||||
console.error('Failed to save chat history', error)
|
||||
}
|
||||
}
|
||||
updateConversationAsync()
|
||||
}, [currentConversationId, chatMessages, createOrUpdateConversation])
|
||||
|
||||
// Updates the currentFile of the focused message (input or chat history)
|
||||
// This happens when active file changes or focused message changes
|
||||
const handleActiveLeafChange = useCallback(() => {
|
||||
const activeFile = app.workspace.getActiveFile()
|
||||
if (!activeFile) return
|
||||
|
||||
const mentionable: Omit<MentionableCurrentFile, 'id'> = {
|
||||
type: 'current-file',
|
||||
file: activeFile,
|
||||
}
|
||||
|
||||
if (!focusedMessageId) return
|
||||
if (inputMessage.id === focusedMessageId) {
|
||||
setInputMessage((prevInputMessage) => ({
|
||||
...prevInputMessage,
|
||||
mentionables: [
|
||||
mentionable,
|
||||
...prevInputMessage.mentionables.filter(
|
||||
(mentionable) => mentionable.type !== 'current-file',
|
||||
),
|
||||
],
|
||||
}))
|
||||
} else {
|
||||
setChatMessages((prevChatHistory) =>
|
||||
prevChatHistory.map((message) =>
|
||||
message.id === focusedMessageId && message.role === 'user'
|
||||
? {
|
||||
...message,
|
||||
mentionables: [
|
||||
mentionable,
|
||||
...message.mentionables.filter(
|
||||
(mentionable) => mentionable.type !== 'current-file',
|
||||
),
|
||||
],
|
||||
}
|
||||
: message,
|
||||
),
|
||||
)
|
||||
}
|
||||
}, [app.workspace, focusedMessageId, inputMessage.id])
|
||||
|
||||
useEffect(() => {
|
||||
app.workspace.on('active-leaf-change', handleActiveLeafChange)
|
||||
return () => {
|
||||
app.workspace.off('active-leaf-change', handleActiveLeafChange)
|
||||
}
|
||||
}, [app.workspace, handleActiveLeafChange])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
openNewChat: (selectedBlock?: MentionableBlockData) =>
|
||||
handleNewChat(selectedBlock),
|
||||
addSelectionToChat: (selectedBlock: MentionableBlockData) => {
|
||||
const mentionable: Omit<MentionableBlock, 'id'> = {
|
||||
type: 'block',
|
||||
...selectedBlock,
|
||||
}
|
||||
|
||||
setAddedBlockKey(getMentionableKey(serializeMentionable(mentionable)))
|
||||
|
||||
if (focusedMessageId === inputMessage.id) {
|
||||
setInputMessage((prevInputMessage) => {
|
||||
const mentionableKey = getMentionableKey(
|
||||
serializeMentionable(mentionable),
|
||||
)
|
||||
// Check if mentionable already exists
|
||||
if (
|
||||
prevInputMessage.mentionables.some(
|
||||
(m) =>
|
||||
getMentionableKey(serializeMentionable(m)) === mentionableKey,
|
||||
)
|
||||
) {
|
||||
return prevInputMessage
|
||||
}
|
||||
return {
|
||||
...prevInputMessage,
|
||||
mentionables: [...prevInputMessage.mentionables, mentionable],
|
||||
}
|
||||
})
|
||||
} else {
|
||||
setChatMessages((prevChatHistory) =>
|
||||
prevChatHistory.map((message) => {
|
||||
if (message.id === focusedMessageId && message.role === 'user') {
|
||||
const mentionableKey = getMentionableKey(
|
||||
serializeMentionable(mentionable),
|
||||
)
|
||||
// Check if mentionable already exists
|
||||
if (
|
||||
message.mentionables.some(
|
||||
(m) =>
|
||||
getMentionableKey(serializeMentionable(m)) ===
|
||||
mentionableKey,
|
||||
)
|
||||
) {
|
||||
return message
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
mentionables: [...message.mentionables, mentionable],
|
||||
}
|
||||
}
|
||||
return message
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
focusMessage: () => {
|
||||
if (!focusedMessageId) return
|
||||
chatUserInputRefs.current.get(focusedMessageId)?.focus()
|
||||
},
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="infio-chat-container">
|
||||
<div className="infio-chat-header">
|
||||
<h1 className="infio-chat-header-title"> CHAT </h1>
|
||||
<div className="infio-chat-header-buttons">
|
||||
<button
|
||||
onClick={() => handleNewChat()}
|
||||
className="infio-chat-list-dropdown"
|
||||
>
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
<ChatListDropdown
|
||||
chatList={chatList}
|
||||
currentConversationId={currentConversationId}
|
||||
onSelect={async (conversationId) => {
|
||||
if (conversationId === currentConversationId) return
|
||||
await handleLoadConversation(conversationId)
|
||||
}}
|
||||
onDelete={async (conversationId) => {
|
||||
await deleteConversation(conversationId)
|
||||
if (conversationId === currentConversationId) {
|
||||
const nextConversation = chatList.find(
|
||||
(chat) => chat.id !== conversationId,
|
||||
)
|
||||
if (nextConversation) {
|
||||
void handleLoadConversation(nextConversation.id)
|
||||
} else {
|
||||
handleNewChat()
|
||||
}
|
||||
}
|
||||
}}
|
||||
onUpdateTitle={async (conversationId, newTitle) => {
|
||||
await updateConversationTitle(conversationId, newTitle)
|
||||
}}
|
||||
className="infio-chat-list-dropdown"
|
||||
>
|
||||
<History size={18} />
|
||||
</ChatListDropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className="infio-chat-messages" ref={chatMessagesRef}>
|
||||
{
|
||||
// If the chat is empty, show a message to start a new chat
|
||||
chatMessages.length === 0 && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
width: '100%'
|
||||
}}>
|
||||
<ShortcutInfo />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{chatMessages.map((message, index) =>
|
||||
message.role === 'user' ? (
|
||||
<div key={message.id} className="infio-chat-messages-user">
|
||||
<ChatUserInput
|
||||
ref={(ref) => registerChatUserInputRef(message.id, ref)}
|
||||
initialSerializedEditorState={message.content}
|
||||
onChange={(content) => {
|
||||
setChatMessages((prevChatHistory) =>
|
||||
prevChatHistory.map((msg) =>
|
||||
msg.role === 'user' && msg.id === message.id
|
||||
? {
|
||||
...msg,
|
||||
content,
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
)
|
||||
}}
|
||||
onSubmit={(content, useVaultSearch) => {
|
||||
if (editorStateToPlainText(content).trim() === '') return
|
||||
handleSubmit(
|
||||
[
|
||||
...chatMessages.slice(0, index),
|
||||
{
|
||||
role: 'user',
|
||||
content: content,
|
||||
promptContent: null,
|
||||
id: message.id,
|
||||
mentionables: message.mentionables,
|
||||
},
|
||||
],
|
||||
useVaultSearch,
|
||||
)
|
||||
chatUserInputRefs.current.get(inputMessage.id)?.focus()
|
||||
}}
|
||||
onFocus={() => {
|
||||
setFocusedMessageId(message.id)
|
||||
}}
|
||||
mentionables={message.mentionables}
|
||||
setMentionables={(mentionables) => {
|
||||
setChatMessages((prevChatHistory) =>
|
||||
prevChatHistory.map((msg) =>
|
||||
msg.id === message.id ? { ...msg, mentionables } : msg,
|
||||
),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
{message.similaritySearchResults && (
|
||||
<SimilaritySearchResults
|
||||
similaritySearchResults={message.similaritySearchResults}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div key={message.id} className="infio-chat-messages-assistant">
|
||||
<ReactMarkdownItem
|
||||
handleApply={handleApply}
|
||||
isApplying={applyMutation.isPending}
|
||||
>
|
||||
{message.content}
|
||||
</ReactMarkdownItem>
|
||||
{message.content && <AssistantMessageActions message={message} />}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<QueryProgress state={queryProgress} />
|
||||
{submitMutation.isPending && (
|
||||
<button onClick={abortActiveStreams} className="infio-stop-gen-btn">
|
||||
<CircleStop size={16} />
|
||||
<div>Stop Generation</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ChatUserInput
|
||||
key={inputMessage.id} // this is needed to clear the editor when the user submits a new message
|
||||
ref={(ref) => registerChatUserInputRef(inputMessage.id, ref)}
|
||||
initialSerializedEditorState={inputMessage.content}
|
||||
onChange={(content) => {
|
||||
setInputMessage((prevInputMessage) => ({
|
||||
...prevInputMessage,
|
||||
content,
|
||||
}))
|
||||
}}
|
||||
onSubmit={(content, useVaultSearch) => {
|
||||
if (editorStateToPlainText(content).trim() === '') return
|
||||
handleSubmit(
|
||||
[...chatMessages, { ...inputMessage, content }],
|
||||
useVaultSearch,
|
||||
)
|
||||
setInputMessage(getNewInputMessage(app))
|
||||
preventAutoScrollRef.current = false
|
||||
handleScrollToBottom()
|
||||
}}
|
||||
onFocus={() => {
|
||||
setFocusedMessageId(inputMessage.id)
|
||||
}}
|
||||
mentionables={inputMessage.mentionables}
|
||||
setMentionables={(mentionables) => {
|
||||
setInputMessage((prevInputMessage) => ({
|
||||
...prevInputMessage,
|
||||
mentionables,
|
||||
}))
|
||||
}}
|
||||
autoFocus
|
||||
addedBlockKey={addedBlockKey}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function ReactMarkdownItem({
|
||||
handleApply,
|
||||
isApplying,
|
||||
children,
|
||||
}: {
|
||||
handleApply: (blockInfo: {
|
||||
content: string
|
||||
filename?: string
|
||||
startLine?: number
|
||||
endLine?: number
|
||||
}) => void
|
||||
isApplying: boolean
|
||||
children: string
|
||||
}) {
|
||||
return (
|
||||
<ReactMarkdown onApply={handleApply} isApplying={isApplying}>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
)
|
||||
}
|
||||
|
||||
Chat.displayName = 'Chat'
|
||||
|
||||
export default Chat
|
||||
202
src/components/chat-view/ChatListDropdown.tsx
Normal file
202
src/components/chat-view/ChatListDropdown.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import * as Popover from '@radix-ui/react-popover'
|
||||
import { Pencil, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { ChatConversationMeta } from '../../types/chat'
|
||||
|
||||
function TitleInput({
|
||||
title,
|
||||
onSubmit,
|
||||
}: {
|
||||
title: string
|
||||
onSubmit: (title: string) => Promise<void>
|
||||
}) {
|
||||
const [value, setValue] = useState(title)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.select()
|
||||
inputRef.current.scrollLeft = 0
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
className="infio-chat-list-dropdown-item-title-input"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Enter') {
|
||||
onSubmit(value)
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
maxLength={100}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatListItem({
|
||||
title,
|
||||
isFocused,
|
||||
isEditing,
|
||||
onMouseEnter,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onStartEdit,
|
||||
onFinishEdit,
|
||||
}: {
|
||||
title: string
|
||||
isFocused: boolean
|
||||
isEditing: boolean
|
||||
onMouseEnter: () => void
|
||||
onSelect: () => Promise<void>
|
||||
onDelete: () => Promise<void>
|
||||
onStartEdit: () => void
|
||||
onFinishEdit: (title: string) => Promise<void>
|
||||
}) {
|
||||
const itemRef = useRef<HTMLLIElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isFocused && itemRef.current) {
|
||||
itemRef.current.scrollIntoView({
|
||||
block: 'nearest',
|
||||
})
|
||||
}
|
||||
}, [isFocused])
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={itemRef}
|
||||
onClick={onSelect}
|
||||
onMouseEnter={onMouseEnter}
|
||||
className={isFocused ? 'selected' : ''}
|
||||
>
|
||||
{isEditing ? (
|
||||
<TitleInput title={title} onSubmit={onFinishEdit} />
|
||||
) : (
|
||||
<div className="infio-chat-list-dropdown-item-title">{title}</div>
|
||||
)}
|
||||
<div className="infio-chat-list-dropdown-item-actions">
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onStartEdit()
|
||||
}}
|
||||
className="infio-chat-list-dropdown-item-icon"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</div>
|
||||
<div
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation()
|
||||
await onDelete()
|
||||
}}
|
||||
className="infio-chat-list-dropdown-item-icon"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatListDropdown({
|
||||
chatList,
|
||||
currentConversationId,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onUpdateTitle,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
chatList: ChatConversationMeta[]
|
||||
currentConversationId: string
|
||||
onSelect: (conversationId: string) => Promise<void>
|
||||
onDelete: (conversationId: string) => Promise<void>
|
||||
onUpdateTitle: (conversationId: string, newTitle: string) => Promise<void>
|
||||
className?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [focusedIndex, setFocusedIndex] = useState<number>(0)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const currentIndex = chatList.findIndex(
|
||||
(chat) => chat.id === currentConversationId,
|
||||
)
|
||||
setFocusedIndex(currentIndex === -1 ? 0 : currentIndex)
|
||||
setEditingId(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowUp') {
|
||||
setFocusedIndex(Math.max(0, focusedIndex - 1))
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
setFocusedIndex(Math.min(chatList.length - 1, focusedIndex + 1))
|
||||
} else if (e.key === 'Enter') {
|
||||
onSelect(chatList[focusedIndex].id)
|
||||
setOpen(false)
|
||||
}
|
||||
},
|
||||
[chatList, focusedIndex, setFocusedIndex, onSelect],
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<button className={className}>{children}</button>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
className="infio-popover infio-chat-list-dropdown-content"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<ul>
|
||||
{chatList.length === 0 ? (
|
||||
<li className="infio-chat-list-dropdown-empty">
|
||||
No conversations
|
||||
</li>
|
||||
) : (
|
||||
chatList.map((chat, index) => (
|
||||
<ChatListItem
|
||||
key={chat.id}
|
||||
title={chat.title}
|
||||
isFocused={focusedIndex === index}
|
||||
isEditing={editingId === chat.id}
|
||||
onMouseEnter={() => {
|
||||
setFocusedIndex(index)
|
||||
}}
|
||||
onSelect={async () => {
|
||||
await onSelect(chat.id)
|
||||
setOpen(false)
|
||||
}}
|
||||
onDelete={async () => {
|
||||
await onDelete(chat.id)
|
||||
}}
|
||||
onStartEdit={() => {
|
||||
setEditingId(chat.id)
|
||||
}}
|
||||
onFinishEdit={async (title) => {
|
||||
await onUpdateTitle(chat.id, title)
|
||||
setEditingId(null)
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
)
|
||||
}
|
||||
127
src/components/chat-view/CreateTemplateDialog.tsx
Normal file
127
src/components/chat-view/CreateTemplateDialog.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { $generateNodesFromSerializedNodes } from '@lexical/clipboard'
|
||||
import { BaseSerializedNode } from '@lexical/clipboard/clipboard'
|
||||
import { InitialEditorStateType } from '@lexical/react/LexicalComposer'
|
||||
import * as Dialog from '@radix-ui/react-dialog'
|
||||
import { $insertNodes, LexicalEditor } from 'lexical'
|
||||
import { X } from 'lucide-react'
|
||||
import { Notice } from 'obsidian'
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
import { useDatabase } from '../../contexts/DatabaseContext'
|
||||
import { useDialogContainer } from '../../contexts/DialogContext'
|
||||
import { DuplicateTemplateException } from '../../database/exception'
|
||||
|
||||
import LexicalContentEditable from './chat-input/LexicalContentEditable'
|
||||
|
||||
/*
|
||||
* This component must be used inside <Dialog.Root modal={false}>
|
||||
* The modal={false} prop is required because modal mode blocks pointer events across the entire page,
|
||||
* which would conflict with lexical editor popovers
|
||||
*/
|
||||
export default function CreateTemplateDialogContent({
|
||||
selectedSerializedNodes,
|
||||
onClose,
|
||||
}: {
|
||||
selectedSerializedNodes?: BaseSerializedNode[] | null
|
||||
onClose: () => void
|
||||
}) {
|
||||
const container = useDialogContainer()
|
||||
const { getTemplateManager } = useDatabase()
|
||||
|
||||
const [templateName, setTemplateName] = useState('')
|
||||
const editorRef = useRef<LexicalEditor | null>(null)
|
||||
const contentEditableRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const initialEditorState: InitialEditorStateType = (
|
||||
editor: LexicalEditor,
|
||||
) => {
|
||||
if (!selectedSerializedNodes) return
|
||||
editor.update(() => {
|
||||
const parsedNodes = $generateNodesFromSerializedNodes(
|
||||
selectedSerializedNodes,
|
||||
)
|
||||
$insertNodes(parsedNodes)
|
||||
})
|
||||
}
|
||||
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
if (!editorRef.current) return
|
||||
const serializedEditorState = editorRef.current.toJSON()
|
||||
const nodes = serializedEditorState.editorState.root.children
|
||||
if (nodes.length === 0) {
|
||||
new Notice('Please enter a content for your template')
|
||||
return
|
||||
}
|
||||
if (templateName.trim().length === 0) {
|
||||
new Notice('Please enter a name for your template')
|
||||
return
|
||||
}
|
||||
|
||||
await (
|
||||
await getTemplateManager()
|
||||
).createTemplate({
|
||||
name: templateName,
|
||||
content: { nodes },
|
||||
})
|
||||
new Notice(`Template created: ${templateName}`)
|
||||
setTemplateName('')
|
||||
onClose()
|
||||
} catch (error) {
|
||||
if (error instanceof DuplicateTemplateException) {
|
||||
new Notice('A template with this name already exists')
|
||||
} else {
|
||||
console.error(error)
|
||||
new Notice('Failed to create template')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog.Portal container={container}>
|
||||
<Dialog.Content className="infio-chat-dialog-content">
|
||||
<div className="infio-dialog-header">
|
||||
<Dialog.Title className="infio-dialog-title">
|
||||
Create template
|
||||
</Dialog.Title>
|
||||
<Dialog.Description className="infio-dialog-description">
|
||||
Create template from selected content
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
|
||||
<div className="infio-dialog-input">
|
||||
<label>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={templateName}
|
||||
onChange={(e) => setTemplateName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
onSubmit()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="infio-chat-user-input-container">
|
||||
<LexicalContentEditable
|
||||
initialEditorState={initialEditorState}
|
||||
editorRef={editorRef}
|
||||
contentEditableRef={contentEditableRef}
|
||||
onEnter={onSubmit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="infio-dialog-footer">
|
||||
<button onClick={onSubmit}>Create template</button>
|
||||
</div>
|
||||
|
||||
<Dialog.Close className="infio-dialog-close" asChild>
|
||||
<X size={16} />
|
||||
</Dialog.Close>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
)
|
||||
}
|
||||
84
src/components/chat-view/LLMResponseInfoPopover.tsx
Normal file
84
src/components/chat-view/LLMResponseInfoPopover.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import * as Popover from '@radix-ui/react-popover'
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowRightLeft,
|
||||
ArrowUp,
|
||||
Coins,
|
||||
Cpu,
|
||||
Info,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { ResponseUsage } from '../../types/llm/response'
|
||||
|
||||
type LLMResponseInfoProps = {
|
||||
usage?: ResponseUsage
|
||||
estimatedPrice: number | null
|
||||
model?: string
|
||||
}
|
||||
|
||||
export default function LLMResponseInfoPopover({
|
||||
usage,
|
||||
estimatedPrice,
|
||||
model,
|
||||
}: LLMResponseInfoProps) {
|
||||
return (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger asChild>
|
||||
<button>
|
||||
<Info className="infio-llm-info-icon--trigger" size={12} />
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
{usage ? (
|
||||
<Popover.Content className="infio-chat-popover-content infio-llm-info-content">
|
||||
<div className="infio-llm-info-header">LLM Response Information</div>
|
||||
<div className="infio-llm-info-tokens">
|
||||
<div className="infio-llm-info-tokens-header">Token Count</div>
|
||||
<div className="infio-llm-info-tokens-grid">
|
||||
<div className="infio-llm-info-token-row">
|
||||
<ArrowUp className="infio-llm-info-icon--input" />
|
||||
<span>Input:</span>
|
||||
<span className="infio-llm-info-token-value">
|
||||
{usage.prompt_tokens}
|
||||
</span>
|
||||
</div>
|
||||
<div className="infio-llm-info-token-row">
|
||||
<ArrowDown className="infio-llm-info-icon--output" />
|
||||
<span>Output:</span>
|
||||
<span className="infio-llm-info-token-value">
|
||||
{usage.completion_tokens}
|
||||
</span>
|
||||
</div>
|
||||
<div className="infio-llm-info-token-row infio-llm-info-token-total">
|
||||
<ArrowRightLeft className="infio-llm-info-icon--total" />
|
||||
<span>Total:</span>
|
||||
<span className="infio-llm-info-token-value">
|
||||
{usage.total_tokens}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="infio-llm-info-footer-row">
|
||||
<Coins className="infio-llm-info-icon--footer" />
|
||||
<span>Estimated Price:</span>
|
||||
<span className="infio-llm-info-footer-value">
|
||||
{estimatedPrice === null
|
||||
? 'Not available'
|
||||
: `$${estimatedPrice.toFixed(4)}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="infio-llm-info-footer-row">
|
||||
<Cpu className="infio-llm-info-icon--footer" />
|
||||
<span>Model:</span>
|
||||
<span className="infio-llm-info-footer-value infio-llm-info-model">
|
||||
{model ?? 'Not available'}
|
||||
</span>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
) : (
|
||||
<Popover.Content className="infio-chat-popover-content">
|
||||
<div>Usage statistics are not available for this model</div>
|
||||
</Popover.Content>
|
||||
)}
|
||||
</Popover.Root>
|
||||
)
|
||||
}
|
||||
122
src/components/chat-view/MarkdownCodeComponent.tsx
Normal file
122
src/components/chat-view/MarkdownCodeComponent.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { Check, CopyIcon, Loader2 } from 'lucide-react'
|
||||
import { PropsWithChildren, useMemo, useState } from 'react'
|
||||
|
||||
import { useDarkModeContext } from '../../contexts/DarkModeContext'
|
||||
|
||||
import { MemoizedSyntaxHighlighterWrapper } from './SyntaxHighlighterWrapper'
|
||||
|
||||
export default function MarkdownCodeComponent({
|
||||
onApply,
|
||||
isApplying,
|
||||
language,
|
||||
filename,
|
||||
startLine,
|
||||
endLine,
|
||||
action,
|
||||
children,
|
||||
}: PropsWithChildren<{
|
||||
onApply: (blockInfo: {
|
||||
content: string
|
||||
filename?: string
|
||||
startLine?: number
|
||||
endLine?: number
|
||||
}) => void
|
||||
isApplying: boolean
|
||||
language?: string
|
||||
filename?: string
|
||||
startLine?: number
|
||||
endLine?: number
|
||||
action?: 'edit' | 'new' | 'reference'
|
||||
}>) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const { isDarkMode } = useDarkModeContext()
|
||||
|
||||
const wrapLines = useMemo(() => {
|
||||
return !language || ['markdown'].includes(language)
|
||||
}, [language])
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(String(children))
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy text: ', err)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`infio-chat-code-block ${filename ? 'has-filename' : ''} ${action ? `type-${action}` : ''}`}>
|
||||
<div className={'infio-chat-code-block-header'}>
|
||||
{filename && (
|
||||
<div className={'infio-chat-code-block-header-filename'}>{filename}</div>
|
||||
)}
|
||||
<div className={'infio-chat-code-block-header-button'}>
|
||||
<button
|
||||
onClick={() => {
|
||||
handleCopy()
|
||||
}}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check size={10} /> Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CopyIcon size={10} /> Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{action === 'edit' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onApply({
|
||||
content: String(children),
|
||||
filename,
|
||||
startLine,
|
||||
endLine
|
||||
})
|
||||
}}
|
||||
disabled={isApplying}
|
||||
>
|
||||
{isApplying ? (
|
||||
<>
|
||||
<Loader2 className="spinner" size={14} /> Applying...
|
||||
</>
|
||||
) : (
|
||||
'Apply'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{action === 'new' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onApply({
|
||||
content: String(children),
|
||||
filename
|
||||
})
|
||||
}}
|
||||
disabled={isApplying}
|
||||
>
|
||||
{isApplying ? (
|
||||
<>
|
||||
<Loader2 className="spinner" size={14} /> Inserting...
|
||||
</>
|
||||
) : (
|
||||
'Insert'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<MemoizedSyntaxHighlighterWrapper
|
||||
isDarkMode={isDarkMode}
|
||||
language={language}
|
||||
hasFilename={!!filename}
|
||||
wrapLines={wrapLines}
|
||||
>
|
||||
{String(children)}
|
||||
</MemoizedSyntaxHighlighterWrapper>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
75
src/components/chat-view/MarkdownReferenceBlock.tsx
Normal file
75
src/components/chat-view/MarkdownReferenceBlock.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { PropsWithChildren, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useApp } from '../../contexts/AppContext'
|
||||
import { useDarkModeContext } from '../../contexts/DarkModeContext'
|
||||
import { openMarkdownFile, readTFileContent } from '../../utils/obsidian'
|
||||
|
||||
import { MemoizedSyntaxHighlighterWrapper } from './SyntaxHighlighterWrapper'
|
||||
|
||||
export default function MarkdownReferenceBlock({
|
||||
filename,
|
||||
startLine,
|
||||
endLine,
|
||||
language,
|
||||
}: PropsWithChildren<{
|
||||
filename: string
|
||||
startLine: number
|
||||
endLine: number
|
||||
language?: string
|
||||
}>) {
|
||||
const app = useApp()
|
||||
const { isDarkMode } = useDarkModeContext()
|
||||
const [blockContent, setBlockContent] = useState<string | null>(null)
|
||||
|
||||
const wrapLines = useMemo(() => {
|
||||
return !language || ['markdown'].includes(language)
|
||||
}, [language])
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchBlockContent() {
|
||||
const file = app.vault.getFileByPath(filename)
|
||||
if (!file) {
|
||||
setBlockContent(null)
|
||||
return
|
||||
}
|
||||
const fileContent = await readTFileContent(file, app.vault)
|
||||
const content = fileContent
|
||||
.split('\n')
|
||||
.slice(startLine - 1, endLine)
|
||||
.join('\n')
|
||||
setBlockContent(content)
|
||||
}
|
||||
|
||||
fetchBlockContent()
|
||||
}, [filename, startLine, endLine, app.vault])
|
||||
|
||||
const handleClick = () => {
|
||||
openMarkdownFile(app, filename, startLine)
|
||||
}
|
||||
|
||||
// TODO: Update styles
|
||||
return (
|
||||
blockContent && (
|
||||
<div
|
||||
className={`infio-chat-code-block ${filename ? 'has-filename' : ''}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className={'infio-chat-code-block-header'}>
|
||||
{filename && (
|
||||
<div className={'infio-chat-code-block-header-filename'}>
|
||||
{filename}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<MemoizedSyntaxHighlighterWrapper
|
||||
isDarkMode={isDarkMode}
|
||||
language={language}
|
||||
hasFilename={!!filename}
|
||||
wrapLines={wrapLines}
|
||||
>
|
||||
{blockContent}
|
||||
</MemoizedSyntaxHighlighterWrapper>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
85
src/components/chat-view/QueryProgress.tsx
Normal file
85
src/components/chat-view/QueryProgress.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { SelectVector } from '../../database/schema'
|
||||
|
||||
export type QueryProgressState =
|
||||
| {
|
||||
type: 'reading-mentionables'
|
||||
}
|
||||
| {
|
||||
type: 'indexing'
|
||||
indexProgress: IndexProgress
|
||||
}
|
||||
| {
|
||||
type: 'querying'
|
||||
}
|
||||
| {
|
||||
type: 'querying-done'
|
||||
queryResult: (Omit<SelectVector, 'embedding'> & { similarity: number })[]
|
||||
}
|
||||
| {
|
||||
type: 'idle'
|
||||
}
|
||||
|
||||
export type IndexProgress = {
|
||||
completedChunks: number
|
||||
totalChunks: number
|
||||
totalFiles: number
|
||||
}
|
||||
|
||||
// TODO: Update style
|
||||
export default function QueryProgress({
|
||||
state,
|
||||
}: {
|
||||
state: QueryProgressState
|
||||
}) {
|
||||
switch (state.type) {
|
||||
case 'idle':
|
||||
return null
|
||||
case 'reading-mentionables':
|
||||
return (
|
||||
<div className="infio-query-progress">
|
||||
<p>
|
||||
Reading mentioned files
|
||||
<DotLoader />
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
case 'indexing':
|
||||
return (
|
||||
<div className="infio-query-progress">
|
||||
<p>
|
||||
{`Indexing ${state.indexProgress.totalFiles} file`}
|
||||
<DotLoader />
|
||||
</p>
|
||||
<p className="infio-query-progress-detail">{`${state.indexProgress.completedChunks}/${state.indexProgress.totalChunks} chunks indexed`}</p>
|
||||
</div>
|
||||
)
|
||||
case 'querying':
|
||||
return (
|
||||
<div className="infio-query-progress">
|
||||
<p>
|
||||
Querying the vault
|
||||
<DotLoader />
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
case 'querying-done':
|
||||
return (
|
||||
<div className="infio-query-progress">
|
||||
<p>
|
||||
Reading related files
|
||||
<DotLoader />
|
||||
</p>
|
||||
{state.queryResult.map((result) => (
|
||||
<div key={result.path}>
|
||||
<p>{result.path}</p>
|
||||
<p>{result.similarity}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function DotLoader() {
|
||||
return <span className="infio-dot-loader" aria-label="Loading"></span>
|
||||
}
|
||||
64
src/components/chat-view/ReactMarkdown.tsx
Normal file
64
src/components/chat-view/ReactMarkdown.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
|
||||
import {
|
||||
ParsedinfioBlock,
|
||||
parseinfioBlocks,
|
||||
} from '../../utils/parse-infio-block'
|
||||
|
||||
import MarkdownCodeComponent from './MarkdownCodeComponent'
|
||||
import MarkdownReferenceBlock from './MarkdownReferenceBlock'
|
||||
|
||||
function ReactMarkdown({
|
||||
onApply,
|
||||
isApplying,
|
||||
children,
|
||||
}: {
|
||||
onApply: (blockInfo: {
|
||||
content: string
|
||||
filename?: string
|
||||
startLine?: number
|
||||
endLine?: number
|
||||
}) => void
|
||||
children: string
|
||||
isApplying: boolean
|
||||
}) {
|
||||
const blocks: ParsedinfioBlock[] = useMemo(
|
||||
() => parseinfioBlocks(children),
|
||||
[children],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{blocks.map((block, index) =>
|
||||
block.type === 'string' ? (
|
||||
<Markdown key={index} className="infio-markdown">
|
||||
{block.content}
|
||||
</Markdown>
|
||||
) : block.startLine && block.endLine && block.filename && block.action === 'reference' ? (
|
||||
<MarkdownReferenceBlock
|
||||
key={index}
|
||||
filename={block.filename}
|
||||
startLine={block.startLine}
|
||||
endLine={block.endLine}
|
||||
/>
|
||||
) : (
|
||||
<MarkdownCodeComponent
|
||||
key={index}
|
||||
onApply={onApply}
|
||||
isApplying={isApplying}
|
||||
language={block.language}
|
||||
filename={block.filename}
|
||||
startLine={block.startLine}
|
||||
endLine={block.endLine}
|
||||
action={block.action}
|
||||
>
|
||||
{block.content}
|
||||
</MarkdownCodeComponent>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ReactMarkdown)
|
||||
38
src/components/chat-view/ShortcutInfo.tsx
Normal file
38
src/components/chat-view/ShortcutInfo.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Platform } from 'obsidian';
|
||||
import React from 'react';
|
||||
|
||||
const ShortcutInfo: React.FC = () => {
|
||||
const modKey = Platform.isMacOS ? 'Cmd' : 'Ctrl';
|
||||
|
||||
const shortcuts = [
|
||||
{
|
||||
label: 'Edit inline',
|
||||
shortcut: `${modKey}+Shift+K`,
|
||||
},
|
||||
{
|
||||
label: 'Chat with select',
|
||||
shortcut: `${modKey}+Shift+L`,
|
||||
},
|
||||
{
|
||||
label: 'Submit with vault',
|
||||
shortcut: `${modKey}+Shift+Enter`,
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="infio-shortcut-info">
|
||||
<table className="infio-shortcut-table">
|
||||
<tbody>
|
||||
{shortcuts.map((item, index) => (
|
||||
<tr key={index} className="infio-shortcut-item">
|
||||
<td className="infio-shortcut-label">{item.label}</td>
|
||||
<td className="infio-shortcut-key"><kbd>{item.shortcut}</kbd></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShortcutInfo;
|
||||
71
src/components/chat-view/SimilaritySearchResults.tsx
Normal file
71
src/components/chat-view/SimilaritySearchResults.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import path from 'path'
|
||||
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useApp } from '../../contexts/AppContext'
|
||||
import { SelectVector } from '../../database/schema'
|
||||
import { openMarkdownFile } from '../../utils/obsidian'
|
||||
|
||||
function SimiliartySearchItem({
|
||||
chunk,
|
||||
}: {
|
||||
chunk: Omit<SelectVector, 'embedding'> & {
|
||||
similarity: number
|
||||
}
|
||||
}) {
|
||||
const app = useApp()
|
||||
|
||||
const handleClick = () => {
|
||||
openMarkdownFile(app, chunk.path, chunk.metadata.startLine)
|
||||
}
|
||||
return (
|
||||
<div onClick={handleClick} className="infio-similarity-search-item">
|
||||
<div className="infio-similarity-search-item__similarity">
|
||||
{chunk.similarity.toFixed(3)}
|
||||
</div>
|
||||
<div className="infio-similarity-search-item__path">
|
||||
{path.basename(chunk.path)}
|
||||
</div>
|
||||
<div className="infio-similarity-search-item__line-numbers">
|
||||
{`${chunk.metadata.startLine} - ${chunk.metadata.endLine}`}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SimilaritySearchResults({
|
||||
similaritySearchResults,
|
||||
}: {
|
||||
similaritySearchResults: (Omit<SelectVector, 'embedding'> & {
|
||||
similarity: number
|
||||
})[]
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="infio-similarity-search-results">
|
||||
<div
|
||||
onClick={() => {
|
||||
setIsOpen(!isOpen)
|
||||
}}
|
||||
className="infio-similarity-search-results__trigger"
|
||||
>
|
||||
{isOpen ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
<div>Show Referenced Documents ({similaritySearchResults.length})</div>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{similaritySearchResults.map((chunk) => (
|
||||
<SimiliartySearchItem key={chunk.id} chunk={chunk} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
src/components/chat-view/SyntaxHighlighterWrapper.tsx
Normal file
51
src/components/chat-view/SyntaxHighlighterWrapper.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { memo } from 'react'
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
|
||||
import {
|
||||
oneDark,
|
||||
oneLight,
|
||||
} from 'react-syntax-highlighter/dist/esm/styles/prism'
|
||||
|
||||
function SyntaxHighlighterWrapper({
|
||||
isDarkMode,
|
||||
language,
|
||||
hasFilename,
|
||||
wrapLines,
|
||||
children,
|
||||
}: {
|
||||
isDarkMode: boolean
|
||||
language: string | undefined
|
||||
hasFilename: boolean
|
||||
wrapLines: boolean
|
||||
children: string
|
||||
}) {
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={isDarkMode ? oneDark : oneLight}
|
||||
customStyle={{
|
||||
borderRadius: hasFilename
|
||||
? '0 0 var(--radius-s) var(--radius-s)'
|
||||
: 'var(--radius-s)',
|
||||
margin: 0,
|
||||
padding: 'var(--size-4-2)',
|
||||
fontSize: 'var(--font-ui-small)',
|
||||
fontFamily:
|
||||
language === 'markdown' ? 'var(--font-interface)' : 'inherit',
|
||||
}}
|
||||
wrapLines={wrapLines}
|
||||
lineProps={
|
||||
// Wrapping should work without lineProps, but Obsidian's default CSS seems to override SyntaxHighlighter's styles.
|
||||
// We manually override the white-space property to ensure proper wrapping.
|
||||
wrapLines
|
||||
? {
|
||||
style: { whiteSpace: 'pre-wrap' },
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</SyntaxHighlighter>
|
||||
)
|
||||
}
|
||||
|
||||
export const MemoizedSyntaxHighlighterWrapper = memo(SyntaxHighlighterWrapper)
|
||||
374
src/components/chat-view/chat-input/ChatUserInput.tsx
Normal file
374
src/components/chat-view/chat-input/ChatUserInput.tsx
Normal file
@@ -0,0 +1,374 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { $nodesOfType, LexicalEditor, SerializedEditorState } from 'lexical'
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
import { useApp } from '../../../contexts/AppContext'
|
||||
import { useDarkModeContext } from '../../../contexts/DarkModeContext'
|
||||
import {
|
||||
Mentionable,
|
||||
MentionableImage,
|
||||
SerializedMentionable,
|
||||
} from '../../../types/mentionable'
|
||||
import { fileToMentionableImage } from '../../../utils/image'
|
||||
import {
|
||||
deserializeMentionable,
|
||||
getMentionableKey,
|
||||
serializeMentionable,
|
||||
} from '../../../utils/mentionable'
|
||||
import { openMarkdownFile, readTFileContent } from '../../../utils/obsidian'
|
||||
import { MemoizedSyntaxHighlighterWrapper } from '../SyntaxHighlighterWrapper'
|
||||
|
||||
import { ImageUploadButton } from './ImageUploadButton'
|
||||
import LexicalContentEditable from './LexicalContentEditable'
|
||||
import MentionableBadge from './MentionableBadge'
|
||||
import { ModelSelect } from './ModelSelect'
|
||||
import { MentionNode } from './plugins/mention/MentionNode'
|
||||
import { NodeMutations } from './plugins/on-mutation/OnMutationPlugin'
|
||||
import { SubmitButton } from './SubmitButton'
|
||||
import { VaultChatButton } from './VaultChatButton'
|
||||
|
||||
export type ChatUserInputRef = {
|
||||
focus: () => void
|
||||
}
|
||||
|
||||
export type ChatUserInputProps = {
|
||||
initialSerializedEditorState: SerializedEditorState | null
|
||||
onChange: (content: SerializedEditorState) => void
|
||||
onSubmit: (content: SerializedEditorState, useVaultSearch?: boolean) => void
|
||||
onFocus: () => void
|
||||
mentionables: Mentionable[]
|
||||
setMentionables: (mentionables: Mentionable[]) => void
|
||||
autoFocus?: boolean
|
||||
addedBlockKey?: string | null
|
||||
}
|
||||
|
||||
const ChatUserInput = forwardRef<ChatUserInputRef, ChatUserInputProps>(
|
||||
(
|
||||
{
|
||||
initialSerializedEditorState,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onFocus,
|
||||
mentionables,
|
||||
setMentionables,
|
||||
autoFocus = false,
|
||||
addedBlockKey,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const app = useApp()
|
||||
|
||||
const editorRef = useRef<LexicalEditor | null>(null)
|
||||
const contentEditableRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [displayedMentionableKey, setDisplayedMentionableKey] = useState<
|
||||
string | null
|
||||
>(addedBlockKey ?? null)
|
||||
|
||||
useEffect(() => {
|
||||
if (addedBlockKey) {
|
||||
setDisplayedMentionableKey(addedBlockKey)
|
||||
}
|
||||
}, [addedBlockKey])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
contentEditableRef.current?.focus()
|
||||
},
|
||||
}))
|
||||
|
||||
const handleMentionNodeMutation = (
|
||||
mutations: NodeMutations<MentionNode>,
|
||||
) => {
|
||||
const destroyedMentionableKeys: string[] = []
|
||||
const addedMentionables: SerializedMentionable[] = []
|
||||
mutations.forEach((mutation) => {
|
||||
const mentionable = mutation.node.getMentionable()
|
||||
const mentionableKey = getMentionableKey(mentionable)
|
||||
|
||||
if (mutation.mutation === 'destroyed') {
|
||||
const nodeWithSameMentionable = editorRef.current?.read(() =>
|
||||
$nodesOfType(MentionNode).find(
|
||||
(node) =>
|
||||
getMentionableKey(node.getMentionable()) === mentionableKey,
|
||||
),
|
||||
)
|
||||
|
||||
if (!nodeWithSameMentionable) {
|
||||
// remove mentionable only if it's not present in the editor state
|
||||
destroyedMentionableKeys.push(mentionableKey)
|
||||
}
|
||||
} else if (mutation.mutation === 'created') {
|
||||
if (
|
||||
mentionables.some(
|
||||
(m) =>
|
||||
getMentionableKey(serializeMentionable(m)) === mentionableKey,
|
||||
) ||
|
||||
addedMentionables.some(
|
||||
(m) => getMentionableKey(m) === mentionableKey,
|
||||
)
|
||||
) {
|
||||
// do nothing if mentionable is already added
|
||||
return
|
||||
}
|
||||
|
||||
addedMentionables.push(mentionable)
|
||||
}
|
||||
})
|
||||
|
||||
setMentionables(
|
||||
mentionables
|
||||
.filter(
|
||||
(m) =>
|
||||
!destroyedMentionableKeys.includes(
|
||||
getMentionableKey(serializeMentionable(m)),
|
||||
),
|
||||
)
|
||||
.concat(
|
||||
addedMentionables
|
||||
.map((m) => deserializeMentionable(m, app))
|
||||
.filter((v) => !!v),
|
||||
),
|
||||
)
|
||||
if (addedMentionables.length > 0) {
|
||||
setDisplayedMentionableKey(
|
||||
getMentionableKey(addedMentionables[addedMentionables.length - 1]),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateImageMentionables = useCallback(
|
||||
(mentionableImages: MentionableImage[]) => {
|
||||
const newMentionableImages = mentionableImages.filter(
|
||||
(m) =>
|
||||
!mentionables.some(
|
||||
(mentionable) =>
|
||||
getMentionableKey(serializeMentionable(mentionable)) ===
|
||||
getMentionableKey(serializeMentionable(m)),
|
||||
),
|
||||
)
|
||||
if (newMentionableImages.length === 0) return
|
||||
setMentionables([...mentionables, ...newMentionableImages])
|
||||
setDisplayedMentionableKey(
|
||||
getMentionableKey(
|
||||
serializeMentionable(
|
||||
newMentionableImages[newMentionableImages.length - 1],
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
[mentionables, setMentionables],
|
||||
)
|
||||
|
||||
const handleMentionableDelete = (mentionable: Mentionable) => {
|
||||
const mentionableKey = getMentionableKey(
|
||||
serializeMentionable(mentionable),
|
||||
)
|
||||
setMentionables(
|
||||
mentionables.filter(
|
||||
(m) => getMentionableKey(serializeMentionable(m)) !== mentionableKey,
|
||||
),
|
||||
)
|
||||
|
||||
editorRef.current?.update(() => {
|
||||
$nodesOfType(MentionNode).forEach((node) => {
|
||||
if (getMentionableKey(node.getMentionable()) === mentionableKey) {
|
||||
node.remove()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const handleUploadImages = async (images: File[]) => {
|
||||
const mentionableImages = await Promise.all(
|
||||
images.map((image) => fileToMentionableImage(image)),
|
||||
)
|
||||
handleCreateImageMentionables(mentionableImages)
|
||||
}
|
||||
|
||||
const handleSubmit = (options: { useVaultSearch?: boolean } = {}) => {
|
||||
const content = editorRef.current?.getEditorState()?.toJSON()
|
||||
content && onSubmit(content, options.useVaultSearch)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="infio-chat-user-input-container" ref={containerRef}>
|
||||
{mentionables.length > 0 && (
|
||||
<div className="infio-chat-user-input-files">
|
||||
{mentionables.map((m) => (
|
||||
<MentionableBadge
|
||||
key={getMentionableKey(serializeMentionable(m))}
|
||||
mentionable={m}
|
||||
onDelete={() => handleMentionableDelete(m)}
|
||||
onClick={() => {
|
||||
const mentionableKey = getMentionableKey(
|
||||
serializeMentionable(m),
|
||||
)
|
||||
if (
|
||||
(m.type === 'current-file' ||
|
||||
m.type === 'file' ||
|
||||
m.type === 'block') &&
|
||||
m.file &&
|
||||
mentionableKey === displayedMentionableKey
|
||||
) {
|
||||
// open file on click again
|
||||
openMarkdownFile(
|
||||
app,
|
||||
m.file.path,
|
||||
m.type === 'block' ? m.startLine : undefined,
|
||||
)
|
||||
} else {
|
||||
setDisplayedMentionableKey(mentionableKey)
|
||||
}
|
||||
}}
|
||||
isFocused={
|
||||
getMentionableKey(serializeMentionable(m)) ===
|
||||
displayedMentionableKey
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MentionableContentPreview
|
||||
displayedMentionableKey={displayedMentionableKey}
|
||||
mentionables={mentionables}
|
||||
/>
|
||||
|
||||
<LexicalContentEditable
|
||||
initialEditorState={(editor) => {
|
||||
if (initialSerializedEditorState) {
|
||||
editor.setEditorState(
|
||||
editor.parseEditorState(initialSerializedEditorState),
|
||||
)
|
||||
}
|
||||
}}
|
||||
editorRef={editorRef}
|
||||
contentEditableRef={contentEditableRef}
|
||||
onChange={onChange}
|
||||
onEnter={() => handleSubmit({ useVaultSearch: false })}
|
||||
onFocus={onFocus}
|
||||
onMentionNodeMutation={handleMentionNodeMutation}
|
||||
onCreateImageMentionables={handleCreateImageMentionables}
|
||||
autoFocus={autoFocus}
|
||||
plugins={{
|
||||
onEnter: {
|
||||
onVaultChat: () => {
|
||||
handleSubmit({ useVaultSearch: true })
|
||||
},
|
||||
},
|
||||
templatePopover: {
|
||||
anchorElement: containerRef.current,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="infio-chat-user-input-controls">
|
||||
<div className="infio-chat-user-input-controls__model-select-container">
|
||||
<ModelSelect />
|
||||
<ImageUploadButton onUpload={handleUploadImages} />
|
||||
</div>
|
||||
<div className="infio-chat-user-input-controls__buttons">
|
||||
<SubmitButton onClick={() => handleSubmit()} />
|
||||
{/* <VaultChatButton
|
||||
onClick={() => {
|
||||
handleSubmit({ useVaultSearch: true })
|
||||
}}
|
||||
/> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
function MentionableContentPreview({
|
||||
displayedMentionableKey,
|
||||
mentionables,
|
||||
}: {
|
||||
displayedMentionableKey: string | null
|
||||
mentionables: Mentionable[]
|
||||
}) {
|
||||
const app = useApp()
|
||||
const { isDarkMode } = useDarkModeContext()
|
||||
|
||||
const displayedMentionable: Mentionable | null = useMemo(() => {
|
||||
return (
|
||||
mentionables.find(
|
||||
(m) =>
|
||||
getMentionableKey(serializeMentionable(m)) ===
|
||||
displayedMentionableKey,
|
||||
) ?? null
|
||||
)
|
||||
}, [displayedMentionableKey, mentionables])
|
||||
|
||||
const { data: displayFileContent } = useQuery({
|
||||
enabled:
|
||||
!!displayedMentionable &&
|
||||
['file', 'current-file', 'block'].includes(displayedMentionable.type),
|
||||
queryKey: [
|
||||
'file',
|
||||
displayedMentionableKey,
|
||||
mentionables.map((m) => getMentionableKey(serializeMentionable(m))), // should be updated when mentionables change (especially on delete)
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!displayedMentionable) return null
|
||||
if (
|
||||
displayedMentionable.type === 'file' ||
|
||||
displayedMentionable.type === 'current-file'
|
||||
) {
|
||||
if (!displayedMentionable.file) return null
|
||||
return await readTFileContent(displayedMentionable.file, app.vault)
|
||||
} else if (displayedMentionable.type === 'block') {
|
||||
const fileContent = await readTFileContent(
|
||||
displayedMentionable.file,
|
||||
app.vault,
|
||||
)
|
||||
|
||||
return fileContent
|
||||
.split('\n')
|
||||
.slice(
|
||||
displayedMentionable.startLine - 1,
|
||||
displayedMentionable.endLine,
|
||||
)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
})
|
||||
|
||||
const displayImage: MentionableImage | null = useMemo(() => {
|
||||
return displayedMentionable?.type === 'image' ? displayedMentionable : null
|
||||
}, [displayedMentionable])
|
||||
|
||||
return displayFileContent ? (
|
||||
<div className="infio-chat-user-input-file-content-preview">
|
||||
<MemoizedSyntaxHighlighterWrapper
|
||||
isDarkMode={isDarkMode}
|
||||
language="markdown"
|
||||
hasFilename={false}
|
||||
wrapLines={false}
|
||||
>
|
||||
{displayFileContent}
|
||||
</MemoizedSyntaxHighlighterWrapper>
|
||||
</div>
|
||||
) : displayImage ? (
|
||||
<div className="infio-chat-user-input-file-content-preview">
|
||||
<img src={displayImage.data} alt={displayImage.name} />
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
|
||||
ChatUserInput.displayName = 'ChatUserInput'
|
||||
|
||||
export default ChatUserInput
|
||||
30
src/components/chat-view/chat-input/ImageUploadButton.tsx
Normal file
30
src/components/chat-view/chat-input/ImageUploadButton.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ImageIcon } from 'lucide-react'
|
||||
|
||||
export function ImageUploadButton({
|
||||
onUpload,
|
||||
}: {
|
||||
onUpload: (files: File[]) => void
|
||||
}) {
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files ?? [])
|
||||
if (files.length > 0) {
|
||||
onUpload(files)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="infio-chat-user-input-submit-button">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<div className="infio-chat-user-input-submit-button-icons">
|
||||
<ImageIcon size={12} />
|
||||
</div>
|
||||
<div>Image</div>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
153
src/components/chat-view/chat-input/LexicalContentEditable.tsx
Normal file
153
src/components/chat-view/chat-input/LexicalContentEditable.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
InitialConfigType,
|
||||
InitialEditorStateType,
|
||||
LexicalComposer,
|
||||
} from '@lexical/react/LexicalComposer'
|
||||
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
|
||||
import { EditorRefPlugin } from '@lexical/react/LexicalEditorRefPlugin'
|
||||
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
|
||||
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
|
||||
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'
|
||||
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
|
||||
import { LexicalEditor, SerializedEditorState } from 'lexical'
|
||||
import { RefObject, useCallback, useEffect } from 'react'
|
||||
|
||||
import { useApp } from '../../../contexts/AppContext'
|
||||
import { MentionableImage } from '../../../types/mentionable'
|
||||
import { fuzzySearch } from '../../../utils/fuzzy-search'
|
||||
|
||||
import DragDropPaste from './plugins/image/DragDropPastePlugin'
|
||||
import ImagePastePlugin from './plugins/image/ImagePastePlugin'
|
||||
import AutoLinkMentionPlugin from './plugins/mention/AutoLinkMentionPlugin'
|
||||
import { MentionNode } from './plugins/mention/MentionNode'
|
||||
import MentionPlugin from './plugins/mention/MentionPlugin'
|
||||
import NoFormatPlugin from './plugins/no-format/NoFormatPlugin'
|
||||
import OnEnterPlugin from './plugins/on-enter/OnEnterPlugin'
|
||||
import OnMutationPlugin, {
|
||||
NodeMutations,
|
||||
} from './plugins/on-mutation/OnMutationPlugin'
|
||||
import CreateTemplatePopoverPlugin from './plugins/template/CreateTemplatePopoverPlugin'
|
||||
import TemplatePlugin from './plugins/template/TemplatePlugin'
|
||||
|
||||
export type LexicalContentEditableProps = {
|
||||
editorRef: RefObject<LexicalEditor>
|
||||
contentEditableRef: RefObject<HTMLDivElement>
|
||||
onChange?: (content: SerializedEditorState) => void
|
||||
onEnter?: (evt: KeyboardEvent) => void
|
||||
onFocus?: () => void
|
||||
onMentionNodeMutation?: (mutations: NodeMutations<MentionNode>) => void
|
||||
onCreateImageMentionables?: (mentionables: MentionableImage[]) => void
|
||||
initialEditorState?: InitialEditorStateType
|
||||
autoFocus?: boolean
|
||||
plugins?: {
|
||||
onEnter?: {
|
||||
onVaultChat: () => void
|
||||
}
|
||||
templatePopover?: {
|
||||
anchorElement: HTMLElement | null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function LexicalContentEditable({
|
||||
editorRef,
|
||||
contentEditableRef,
|
||||
onChange,
|
||||
onEnter,
|
||||
onFocus,
|
||||
onMentionNodeMutation,
|
||||
onCreateImageMentionables,
|
||||
initialEditorState,
|
||||
autoFocus = false,
|
||||
plugins,
|
||||
}: LexicalContentEditableProps) {
|
||||
const app = useApp()
|
||||
|
||||
const initialConfig: InitialConfigType = {
|
||||
namespace: 'LexicalContentEditable',
|
||||
theme: {
|
||||
root: 'infio-chat-lexical-content-editable-root',
|
||||
paragraph: 'infio-chat-lexical-content-editable-paragraph',
|
||||
},
|
||||
nodes: [MentionNode],
|
||||
editorState: initialEditorState,
|
||||
onError: (error) => {
|
||||
console.error(error)
|
||||
},
|
||||
}
|
||||
|
||||
const searchResultByQuery = useCallback(
|
||||
(query: string) => fuzzySearch(app, query),
|
||||
[app],
|
||||
)
|
||||
|
||||
/*
|
||||
* Using requestAnimationFrame for autoFocus instead of using editor.focus()
|
||||
* due to known issues with editor.focus() when initialConfig.editorState is set
|
||||
* See: https://github.com/facebook/lexical/issues/4460
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (autoFocus) {
|
||||
requestAnimationFrame(() => {
|
||||
contentEditableRef.current?.focus()
|
||||
})
|
||||
}
|
||||
}, [autoFocus, contentEditableRef])
|
||||
|
||||
return (
|
||||
<LexicalComposer initialConfig={initialConfig}>
|
||||
{/*
|
||||
There was two approach to make mentionable node copy and pasteable.
|
||||
1. use RichTextPlugin and reset text format when paste
|
||||
- so I implemented NoFormatPlugin to reset text format when paste
|
||||
2. use PlainTextPlugin and override paste command
|
||||
- PlainTextPlugin only pastes text, so we need to implement custom paste handler.
|
||||
- https://github.com/facebook/lexical/discussions/5112
|
||||
*/}
|
||||
<RichTextPlugin
|
||||
contentEditable={
|
||||
<ContentEditable
|
||||
className="obsidian-default-textarea"
|
||||
style={{
|
||||
background: 'transparent',
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
ref={contentEditableRef}
|
||||
/>
|
||||
}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
<HistoryPlugin />
|
||||
<MentionPlugin searchResultByQuery={searchResultByQuery} />
|
||||
<OnChangePlugin
|
||||
onChange={(editorState) => {
|
||||
onChange?.(editorState.toJSON())
|
||||
}}
|
||||
/>
|
||||
{onEnter && (
|
||||
<OnEnterPlugin
|
||||
onEnter={onEnter}
|
||||
onVaultChat={plugins?.onEnter?.onVaultChat}
|
||||
/>
|
||||
)}
|
||||
<OnMutationPlugin
|
||||
nodeClass={MentionNode}
|
||||
onMutation={(mutations) => {
|
||||
onMentionNodeMutation?.(mutations)
|
||||
}}
|
||||
/>
|
||||
<EditorRefPlugin editorRef={editorRef} />
|
||||
<NoFormatPlugin />
|
||||
<AutoLinkMentionPlugin />
|
||||
<ImagePastePlugin onCreateImageMentionables={onCreateImageMentionables} />
|
||||
<DragDropPaste onCreateImageMentionables={onCreateImageMentionables} />
|
||||
<TemplatePlugin />
|
||||
{plugins?.templatePopover && (
|
||||
<CreateTemplatePopoverPlugin
|
||||
anchorElement={plugins.templatePopover.anchorElement}
|
||||
contentEditableElement={contentEditableRef.current}
|
||||
/>
|
||||
)}
|
||||
</LexicalComposer>
|
||||
)
|
||||
}
|
||||
319
src/components/chat-view/chat-input/MentionableBadge.tsx
Normal file
319
src/components/chat-view/chat-input/MentionableBadge.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import { X } from 'lucide-react'
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
import {
|
||||
Mentionable,
|
||||
MentionableBlock,
|
||||
MentionableCurrentFile,
|
||||
MentionableFile,
|
||||
MentionableFolder,
|
||||
MentionableImage,
|
||||
MentionableUrl,
|
||||
MentionableVault,
|
||||
} from '../../../types/mentionable'
|
||||
|
||||
import { getMentionableIcon } from './utils/get-metionable-icon'
|
||||
|
||||
function BadgeBase({
|
||||
children,
|
||||
onDelete,
|
||||
onClick,
|
||||
isFocused,
|
||||
}: PropsWithChildren<{
|
||||
onDelete: () => void
|
||||
onClick: () => void
|
||||
isFocused: boolean
|
||||
}>) {
|
||||
return (
|
||||
<div
|
||||
className={`infio-chat-user-input-file-badge ${isFocused ? 'infio-chat-user-input-file-badge-focused' : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
<div
|
||||
className="infio-chat-user-input-file-badge-delete"
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
>
|
||||
<X size={10} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FileBadge({
|
||||
mentionable,
|
||||
onDelete,
|
||||
onClick,
|
||||
isFocused,
|
||||
}: {
|
||||
mentionable: MentionableFile
|
||||
onDelete: () => void
|
||||
onClick: () => void
|
||||
isFocused: boolean
|
||||
}) {
|
||||
const Icon = getMentionableIcon(mentionable)
|
||||
return (
|
||||
<BadgeBase onDelete={onDelete} onClick={onClick} isFocused={isFocused}>
|
||||
<div className="infio-chat-user-input-file-badge-name">
|
||||
{Icon && (
|
||||
<Icon
|
||||
size={10}
|
||||
className="infio-chat-user-input-file-badge-name-icon"
|
||||
/>
|
||||
)}
|
||||
<span>{mentionable.file.name}</span>
|
||||
</div>
|
||||
</BadgeBase>
|
||||
)
|
||||
}
|
||||
|
||||
function FolderBadge({
|
||||
mentionable,
|
||||
onDelete,
|
||||
onClick,
|
||||
isFocused,
|
||||
}: {
|
||||
mentionable: MentionableFolder
|
||||
onDelete: () => void
|
||||
onClick: () => void
|
||||
isFocused: boolean
|
||||
}) {
|
||||
const Icon = getMentionableIcon(mentionable)
|
||||
return (
|
||||
<BadgeBase onDelete={onDelete} onClick={onClick} isFocused={isFocused}>
|
||||
<div className="infio-chat-user-input-file-badge-name">
|
||||
{Icon && (
|
||||
<Icon
|
||||
size={10}
|
||||
className="infio-chat-user-input-file-badge-name-icon"
|
||||
/>
|
||||
)}
|
||||
<span>{mentionable.folder.name}</span>
|
||||
</div>
|
||||
</BadgeBase>
|
||||
)
|
||||
}
|
||||
|
||||
function VaultBadge({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
mentionable,
|
||||
onDelete,
|
||||
onClick,
|
||||
isFocused,
|
||||
}: {
|
||||
mentionable: MentionableVault
|
||||
onDelete: () => void
|
||||
onClick: () => void
|
||||
isFocused: boolean
|
||||
}) {
|
||||
const Icon = getMentionableIcon(mentionable)
|
||||
return (
|
||||
<BadgeBase onDelete={onDelete} onClick={onClick} isFocused={isFocused}>
|
||||
{/* TODO: Update style */}
|
||||
<div className="infio-chat-user-input-file-badge-name">
|
||||
{Icon && (
|
||||
<Icon
|
||||
size={10}
|
||||
className="infio-chat-user-input-file-badge-name-icon"
|
||||
/>
|
||||
)}
|
||||
<span>Vault</span>
|
||||
</div>
|
||||
</BadgeBase>
|
||||
)
|
||||
}
|
||||
|
||||
function CurrentFileBadge({
|
||||
mentionable,
|
||||
onDelete,
|
||||
onClick,
|
||||
isFocused,
|
||||
}: {
|
||||
mentionable: MentionableCurrentFile
|
||||
onDelete: () => void
|
||||
onClick: () => void
|
||||
isFocused: boolean
|
||||
}) {
|
||||
const Icon = getMentionableIcon(mentionable)
|
||||
return mentionable.file ? (
|
||||
<BadgeBase onDelete={onDelete} onClick={onClick} isFocused={isFocused}>
|
||||
<div className="infio-chat-user-input-file-badge-name">
|
||||
{Icon && (
|
||||
<Icon
|
||||
size={10}
|
||||
className="infio-chat-user-input-file-badge-name-icon"
|
||||
/>
|
||||
)}
|
||||
<span>{mentionable.file.name}</span>
|
||||
</div>
|
||||
<div className="infio-chat-user-input-file-badge-name-block-suffix">
|
||||
{' (Current File)'}
|
||||
</div>
|
||||
</BadgeBase>
|
||||
) : null
|
||||
}
|
||||
|
||||
function BlockBadge({
|
||||
mentionable,
|
||||
onDelete,
|
||||
onClick,
|
||||
isFocused,
|
||||
}: {
|
||||
mentionable: MentionableBlock
|
||||
onDelete: () => void
|
||||
onClick: () => void
|
||||
isFocused: boolean
|
||||
}) {
|
||||
const Icon = getMentionableIcon(mentionable)
|
||||
return (
|
||||
<BadgeBase onDelete={onDelete} onClick={onClick} isFocused={isFocused}>
|
||||
<div className="infio-chat-user-input-file-badge-name-block-name">
|
||||
{Icon && (
|
||||
<Icon
|
||||
size={10}
|
||||
className="infio-chat-user-input-file-badge-name-block-name-icon"
|
||||
/>
|
||||
)}
|
||||
<span>{mentionable.file.name}</span>
|
||||
</div>
|
||||
<div className="infio-chat-user-input-file-badge-name-block-suffix">
|
||||
{` (${mentionable.startLine}:${mentionable.endLine})`}
|
||||
</div>
|
||||
</BadgeBase>
|
||||
)
|
||||
}
|
||||
|
||||
function UrlBadge({
|
||||
mentionable,
|
||||
onDelete,
|
||||
onClick,
|
||||
isFocused,
|
||||
}: {
|
||||
mentionable: MentionableUrl
|
||||
onDelete: () => void
|
||||
onClick: () => void
|
||||
isFocused: boolean
|
||||
}) {
|
||||
const Icon = getMentionableIcon(mentionable)
|
||||
return (
|
||||
<BadgeBase onDelete={onDelete} onClick={onClick} isFocused={isFocused}>
|
||||
<div className="infio-chat-user-input-file-badge-name">
|
||||
{Icon && (
|
||||
<Icon
|
||||
size={10}
|
||||
className="infio-chat-user-input-file-badge-name-icon"
|
||||
/>
|
||||
)}
|
||||
<span>{mentionable.url}</span>
|
||||
</div>
|
||||
</BadgeBase>
|
||||
)
|
||||
}
|
||||
|
||||
function ImageBadge({
|
||||
mentionable,
|
||||
onDelete,
|
||||
onClick,
|
||||
isFocused,
|
||||
}: {
|
||||
mentionable: MentionableImage
|
||||
onDelete: () => void
|
||||
onClick: () => void
|
||||
isFocused: boolean
|
||||
}) {
|
||||
const Icon = getMentionableIcon(mentionable)
|
||||
return (
|
||||
<BadgeBase onDelete={onDelete} onClick={onClick} isFocused={isFocused}>
|
||||
<div className="infio-chat-user-input-file-badge-name">
|
||||
{Icon && (
|
||||
<Icon
|
||||
size={10}
|
||||
className="infio-chat-user-input-file-badge-name-icon"
|
||||
/>
|
||||
)}
|
||||
<span>{mentionable.name}</span>
|
||||
</div>
|
||||
</BadgeBase>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MentionableBadge({
|
||||
mentionable,
|
||||
onDelete,
|
||||
onClick,
|
||||
isFocused = false,
|
||||
}: {
|
||||
mentionable: Mentionable
|
||||
onDelete: () => void
|
||||
onClick: () => void
|
||||
isFocused?: boolean
|
||||
}) {
|
||||
switch (mentionable.type) {
|
||||
case 'file':
|
||||
return (
|
||||
<FileBadge
|
||||
mentionable={mentionable}
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
isFocused={isFocused}
|
||||
/>
|
||||
)
|
||||
case 'folder':
|
||||
return (
|
||||
<FolderBadge
|
||||
mentionable={mentionable}
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
isFocused={isFocused}
|
||||
/>
|
||||
)
|
||||
case 'vault':
|
||||
return (
|
||||
<VaultBadge
|
||||
mentionable={mentionable}
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
isFocused={isFocused}
|
||||
/>
|
||||
)
|
||||
case 'current-file':
|
||||
return (
|
||||
<CurrentFileBadge
|
||||
mentionable={mentionable}
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
isFocused={isFocused}
|
||||
/>
|
||||
)
|
||||
case 'block':
|
||||
return (
|
||||
<BlockBadge
|
||||
mentionable={mentionable}
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
isFocused={isFocused}
|
||||
/>
|
||||
)
|
||||
case 'url':
|
||||
return (
|
||||
<UrlBadge
|
||||
mentionable={mentionable}
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
isFocused={isFocused}
|
||||
/>
|
||||
)
|
||||
case 'image':
|
||||
return (
|
||||
<ImageBadge
|
||||
mentionable={mentionable}
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
isFocused={isFocused}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
51
src/components/chat-view/chat-input/ModelSelect.tsx
Normal file
51
src/components/chat-view/chat-input/ModelSelect.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useSettings } from '../../../contexts/SettingsContext'
|
||||
|
||||
export function ModelSelect() {
|
||||
const { settings, setSettings } = useSettings()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const activeModels = settings.activeModels.filter((model) => model.enabled)
|
||||
|
||||
return (
|
||||
<DropdownMenu.Root open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenu.Trigger className="infio-chat-input-model-select">
|
||||
<div className="infio-chat-input-model-select__icon">
|
||||
{isOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
</div>
|
||||
<div className="infio-chat-input-model-select__model-name">
|
||||
{
|
||||
activeModels.find(
|
||||
(option) => option.name === settings.chatModelId,
|
||||
)?.name
|
||||
}
|
||||
</div>
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
className="infio-popover">
|
||||
<ul>
|
||||
{activeModels.map((model) => (
|
||||
<DropdownMenu.Item
|
||||
key={model.name}
|
||||
onSelect={() => {
|
||||
setSettings({
|
||||
...settings,
|
||||
chatModelId: model.name,
|
||||
})
|
||||
}}
|
||||
asChild
|
||||
>
|
||||
<li>{model.name}</li>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</ul>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
)
|
||||
}
|
||||
12
src/components/chat-view/chat-input/SubmitButton.tsx
Normal file
12
src/components/chat-view/chat-input/SubmitButton.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { CornerDownLeftIcon } from 'lucide-react'
|
||||
|
||||
export function SubmitButton({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<button className="infio-chat-user-input-submit-button" onClick={onClick}>
|
||||
<div>submit</div>
|
||||
<div className="infio-chat-user-input-submit-button-icons">
|
||||
<CornerDownLeftIcon size={12} />
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
42
src/components/chat-view/chat-input/VaultChatButton.tsx
Normal file
42
src/components/chat-view/chat-input/VaultChatButton.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import {
|
||||
ArrowBigUp,
|
||||
ChevronUp,
|
||||
Command,
|
||||
CornerDownLeftIcon,
|
||||
} from 'lucide-react'
|
||||
import { Platform } from 'obsidian'
|
||||
|
||||
export function VaultChatButton({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<>
|
||||
<Tooltip.Provider delayDuration={0}>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
className="infio-chat-user-input-vault-button"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div>vault</div>
|
||||
<div className="infio-chat-user-input-vault-button-icons">
|
||||
{Platform.isMacOS ? (
|
||||
<Command size={10} />
|
||||
) : (
|
||||
<ChevronUp size={12} />
|
||||
)}
|
||||
{/* TODO: Replace with a custom icon */}
|
||||
{/* <ArrowBigUp size={12} /> */}
|
||||
<CornerDownLeftIcon size={12} />
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="infio-tooltip-content" sideOffset={5}>
|
||||
Chat with your entire vault
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { DRAG_DROP_PASTE } from '@lexical/rich-text'
|
||||
import { COMMAND_PRIORITY_LOW } from 'lexical'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { MentionableImage } from '../../../../../types/mentionable'
|
||||
import { fileToMentionableImage } from '../../../../../utils/image'
|
||||
|
||||
export default function DragDropPaste({
|
||||
onCreateImageMentionables,
|
||||
}: {
|
||||
onCreateImageMentionables?: (mentionables: MentionableImage[]) => void
|
||||
}): null {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerCommand(
|
||||
DRAG_DROP_PASTE, // dispatched in RichTextPlugin
|
||||
(files) => {
|
||||
; (async () => {
|
||||
const images = files.filter((file) => file.type.startsWith('image/'))
|
||||
const mentionableImages = await Promise.all(
|
||||
images.map(async (image) => await fileToMentionableImage(image)),
|
||||
)
|
||||
onCreateImageMentionables?.(mentionableImages)
|
||||
})()
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
)
|
||||
}, [editor, onCreateImageMentionables])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { COMMAND_PRIORITY_LOW, PASTE_COMMAND, PasteCommandType } from 'lexical'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { MentionableImage } from '../../../../../types/mentionable'
|
||||
import { fileToMentionableImage } from '../../../../../utils/image'
|
||||
|
||||
export default function ImagePastePlugin({
|
||||
onCreateImageMentionables,
|
||||
}: {
|
||||
onCreateImageMentionables?: (mentionables: MentionableImage[]) => void
|
||||
}) {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
const handlePaste = (event: PasteCommandType) => {
|
||||
const clipboardData =
|
||||
event instanceof ClipboardEvent ? event.clipboardData : null
|
||||
if (!clipboardData) return false
|
||||
|
||||
const images = Array.from(clipboardData.files).filter((file) =>
|
||||
file.type.startsWith('image/'),
|
||||
)
|
||||
if (images.length === 0) return false
|
||||
|
||||
Promise.all(images.map((image) => fileToMentionableImage(image))).then(
|
||||
(mentionableImages) => {
|
||||
onCreateImageMentionables?.(mentionableImages)
|
||||
},
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
return editor.registerCommand(
|
||||
PASTE_COMMAND,
|
||||
handlePaste,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
)
|
||||
}, [editor, onCreateImageMentionables])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import {
|
||||
$createTextNode,
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
PASTE_COMMAND,
|
||||
PasteCommandType,
|
||||
TextNode,
|
||||
} from 'lexical'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { Mentionable, MentionableUrl } from '../../../../../types/mentionable'
|
||||
import {
|
||||
getMentionableName,
|
||||
serializeMentionable,
|
||||
} from '../../../../../utils/mentionable'
|
||||
|
||||
import { $createMentionNode } from './MentionNode'
|
||||
|
||||
const URL_MATCHER =
|
||||
/^((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)$/
|
||||
|
||||
type URLMatch = {
|
||||
index: number
|
||||
length: number
|
||||
text: string
|
||||
url: string
|
||||
}
|
||||
|
||||
function findURLs(text: string): URLMatch[] {
|
||||
const urls: URLMatch[] = []
|
||||
|
||||
let lastIndex = 0
|
||||
for (const word of text.split(' ')) {
|
||||
if (URL_MATCHER.test(word)) {
|
||||
urls.push({
|
||||
index: lastIndex,
|
||||
length: word.length,
|
||||
text: word,
|
||||
url: word.startsWith('http') ? word : `https://${word}`,
|
||||
// attributes: { rel: 'noreferrer', target: '_blank' }, // Optional link attributes
|
||||
})
|
||||
}
|
||||
|
||||
lastIndex += word.length + 1 // +1 for space
|
||||
}
|
||||
|
||||
return urls
|
||||
}
|
||||
|
||||
function $textNodeTransform(node: TextNode) {
|
||||
if (!node.isSimpleText()) {
|
||||
return
|
||||
}
|
||||
|
||||
const text = node.getTextContent()
|
||||
|
||||
// Find only 1st occurrence as transform will be re-run anyway for the rest
|
||||
// because newly inserted nodes are considered to be dirty
|
||||
const urlMatches = findURLs(text)
|
||||
if (urlMatches.length === 0) {
|
||||
return
|
||||
}
|
||||
const urlMatch = urlMatches[0]
|
||||
|
||||
// Get the current selection
|
||||
const selection = $getSelection()
|
||||
|
||||
// Check if the selection is a RangeSelection and the cursor is at the end of the URL
|
||||
if (
|
||||
$isRangeSelection(selection) &&
|
||||
selection.anchor.key === node.getKey() &&
|
||||
selection.focus.key === node.getKey() &&
|
||||
selection.anchor.offset === urlMatch.index + urlMatch.length &&
|
||||
selection.focus.offset === urlMatch.index + urlMatch.length
|
||||
) {
|
||||
// If the cursor is at the end of the URL, don't transform
|
||||
return
|
||||
}
|
||||
|
||||
let targetNode
|
||||
if (urlMatch.index === 0) {
|
||||
// First text chunk within string, splitting into 2 parts
|
||||
;[targetNode] = node.splitText(urlMatch.index + urlMatch.length)
|
||||
} else {
|
||||
// In the middle of a string
|
||||
;[, targetNode] = node.splitText(
|
||||
urlMatch.index,
|
||||
urlMatch.index + urlMatch.length,
|
||||
)
|
||||
}
|
||||
|
||||
const mentionable: MentionableUrl = {
|
||||
type: 'url',
|
||||
url: urlMatch.url,
|
||||
}
|
||||
|
||||
const mentionNode = $createMentionNode(
|
||||
getMentionableName(mentionable),
|
||||
serializeMentionable(mentionable),
|
||||
)
|
||||
|
||||
targetNode.replace(mentionNode)
|
||||
|
||||
const spaceNode = $createTextNode(' ')
|
||||
mentionNode.insertAfter(spaceNode)
|
||||
|
||||
spaceNode.select()
|
||||
}
|
||||
|
||||
function $handlePaste(event: PasteCommandType) {
|
||||
const clipboardData =
|
||||
event instanceof ClipboardEvent ? event.clipboardData : null
|
||||
|
||||
if (!clipboardData) return false
|
||||
|
||||
const text = clipboardData.getData('text/plain')
|
||||
|
||||
const urlMatches = findURLs(text)
|
||||
if (urlMatches.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const selection = $getSelection()
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const nodes = []
|
||||
const addedMentionables: Mentionable[] = []
|
||||
let lastIndex = 0
|
||||
|
||||
urlMatches.forEach((urlMatch) => {
|
||||
// Add text node for unmatched part
|
||||
if (urlMatch.index > lastIndex) {
|
||||
nodes.push($createTextNode(text.slice(lastIndex, urlMatch.index)))
|
||||
}
|
||||
|
||||
const mentionable: MentionableUrl = {
|
||||
type: 'url',
|
||||
url: urlMatch.url,
|
||||
}
|
||||
|
||||
// Add mention node
|
||||
nodes.push(
|
||||
$createMentionNode(urlMatch.text, serializeMentionable(mentionable)),
|
||||
)
|
||||
addedMentionables.push(mentionable)
|
||||
|
||||
lastIndex = urlMatch.index + urlMatch.length
|
||||
|
||||
// Add space node after mention if next character is not space or end of string
|
||||
if (lastIndex >= text.length || text[lastIndex] !== ' ') {
|
||||
nodes.push($createTextNode(' '))
|
||||
}
|
||||
})
|
||||
|
||||
// Add remaining text if any
|
||||
if (lastIndex < text.length) {
|
||||
nodes.push($createTextNode(text.slice(lastIndex)))
|
||||
}
|
||||
|
||||
selection.insertNodes(nodes)
|
||||
return true
|
||||
}
|
||||
|
||||
export default function AutoLinkMentionPlugin() {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
editor.registerCommand(PASTE_COMMAND, $handlePaste, COMMAND_PRIORITY_LOW)
|
||||
|
||||
editor.registerNodeTransform(TextNode, $textNodeTransform)
|
||||
}, [editor])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license.
|
||||
* Original source: https://github.com/facebook/lexical
|
||||
*
|
||||
* Modified from the original code
|
||||
*/
|
||||
|
||||
import {
|
||||
$applyNodeReplacement,
|
||||
DOMConversionMap,
|
||||
DOMConversionOutput,
|
||||
DOMExportOutput,
|
||||
type EditorConfig,
|
||||
type LexicalNode,
|
||||
type NodeKey,
|
||||
type SerializedTextNode,
|
||||
type Spread,
|
||||
TextNode,
|
||||
} from 'lexical'
|
||||
|
||||
import { SerializedMentionable } from '../../../../../types/mentionable'
|
||||
|
||||
export const MENTION_NODE_TYPE = 'mention'
|
||||
export const MENTION_NODE_ATTRIBUTE = 'data-lexical-mention'
|
||||
export const MENTION_NODE_MENTION_NAME_ATTRIBUTE = 'data-lexical-mention-name'
|
||||
export const MENTION_NODE_MENTIONABLE_ATTRIBUTE = 'data-lexical-mentionable'
|
||||
|
||||
export type SerializedMentionNode = Spread<
|
||||
{
|
||||
mentionName: string
|
||||
mentionable: SerializedMentionable
|
||||
},
|
||||
SerializedTextNode
|
||||
>
|
||||
|
||||
function $convertMentionElement(
|
||||
domNode: HTMLElement,
|
||||
): DOMConversionOutput | null {
|
||||
const textContent = domNode.textContent
|
||||
const mentionName =
|
||||
domNode.getAttribute(MENTION_NODE_MENTION_NAME_ATTRIBUTE) ??
|
||||
domNode.textContent ??
|
||||
''
|
||||
const mentionable = JSON.parse(
|
||||
domNode.getAttribute(MENTION_NODE_MENTIONABLE_ATTRIBUTE) ?? '{}',
|
||||
)
|
||||
|
||||
if (textContent !== null) {
|
||||
const node = $createMentionNode(
|
||||
mentionName,
|
||||
mentionable as SerializedMentionable,
|
||||
)
|
||||
return {
|
||||
node,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export class MentionNode extends TextNode {
|
||||
__mentionName: string
|
||||
__mentionable: SerializedMentionable
|
||||
|
||||
static getType(): string {
|
||||
return MENTION_NODE_TYPE
|
||||
}
|
||||
|
||||
static clone(node: MentionNode): MentionNode {
|
||||
return new MentionNode(node.__mentionName, node.__mentionable, node.__key)
|
||||
}
|
||||
static importJSON(serializedNode: SerializedMentionNode): MentionNode {
|
||||
const node = $createMentionNode(
|
||||
serializedNode.mentionName,
|
||||
serializedNode.mentionable,
|
||||
)
|
||||
node.setTextContent(serializedNode.text)
|
||||
node.setFormat(serializedNode.format)
|
||||
node.setDetail(serializedNode.detail)
|
||||
node.setMode(serializedNode.mode)
|
||||
node.setStyle(serializedNode.style)
|
||||
return node
|
||||
}
|
||||
|
||||
constructor(
|
||||
mentionName: string,
|
||||
mentionable: SerializedMentionable,
|
||||
key?: NodeKey,
|
||||
) {
|
||||
super(`@${mentionName}`, key)
|
||||
this.__mentionName = mentionName
|
||||
this.__mentionable = mentionable
|
||||
}
|
||||
|
||||
exportJSON(): SerializedMentionNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
mentionName: this.__mentionName,
|
||||
mentionable: this.__mentionable,
|
||||
type: MENTION_NODE_TYPE,
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
createDOM(config: EditorConfig): HTMLElement {
|
||||
const dom = super.createDOM(config)
|
||||
dom.className = MENTION_NODE_TYPE
|
||||
return dom
|
||||
}
|
||||
|
||||
exportDOM(): DOMExportOutput {
|
||||
const element = document.createElement('span')
|
||||
element.setAttribute(MENTION_NODE_ATTRIBUTE, 'true')
|
||||
element.setAttribute(
|
||||
MENTION_NODE_MENTION_NAME_ATTRIBUTE,
|
||||
this.__mentionName,
|
||||
)
|
||||
element.setAttribute(
|
||||
MENTION_NODE_MENTIONABLE_ATTRIBUTE,
|
||||
JSON.stringify(this.__mentionable),
|
||||
)
|
||||
element.textContent = this.__text
|
||||
return { element }
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
return {
|
||||
span: (domNode: HTMLElement) => {
|
||||
if (
|
||||
!domNode.hasAttribute(MENTION_NODE_ATTRIBUTE) ||
|
||||
!domNode.hasAttribute(MENTION_NODE_MENTION_NAME_ATTRIBUTE) ||
|
||||
!domNode.hasAttribute(MENTION_NODE_MENTIONABLE_ATTRIBUTE)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
conversion: $convertMentionElement,
|
||||
priority: 1,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
isTextEntity(): true {
|
||||
return true
|
||||
}
|
||||
|
||||
canInsertTextBefore(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
canInsertTextAfter(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
getMentionable(): SerializedMentionable {
|
||||
return this.__mentionable
|
||||
}
|
||||
}
|
||||
|
||||
export function $createMentionNode(
|
||||
mentionName: string,
|
||||
mentionable: SerializedMentionable,
|
||||
): MentionNode {
|
||||
const mentionNode = new MentionNode(mentionName, mentionable)
|
||||
mentionNode.setMode('token').toggleDirectionless()
|
||||
return $applyNodeReplacement(mentionNode)
|
||||
}
|
||||
|
||||
export function $isMentionNode(
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is MentionNode {
|
||||
return node instanceof MentionNode
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license.
|
||||
* Original source: https://github.com/facebook/lexical
|
||||
*
|
||||
* Modified from the original code
|
||||
*/
|
||||
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { $createTextNode, COMMAND_PRIORITY_NORMAL, TextNode } from 'lexical'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import { Mentionable } from '../../../../../types/mentionable'
|
||||
import { SearchableMentionable } from '../../../../../utils/fuzzy-search'
|
||||
import {
|
||||
getMentionableName,
|
||||
serializeMentionable,
|
||||
} from '../../../../../utils/mentionable'
|
||||
import { getMentionableIcon } from '../../utils/get-metionable-icon'
|
||||
import { MenuOption, MenuTextMatch } from '../shared/LexicalMenu'
|
||||
import {
|
||||
LexicalTypeaheadMenuPlugin,
|
||||
useBasicTypeaheadTriggerMatch,
|
||||
} from '../typeahead-menu/LexicalTypeaheadMenuPlugin'
|
||||
|
||||
import { $createMentionNode } from './MentionNode'
|
||||
|
||||
const PUNCTUATION =
|
||||
'\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;'
|
||||
const NAME = '\\b[A-Z][^\\s' + PUNCTUATION + ']'
|
||||
|
||||
const DocumentMentionsRegex = {
|
||||
NAME,
|
||||
PUNCTUATION,
|
||||
}
|
||||
|
||||
const PUNC = DocumentMentionsRegex.PUNCTUATION
|
||||
|
||||
const TRIGGERS = ['@'].join('')
|
||||
|
||||
// Chars we expect to see in a mention (non-space, non-punctuation).
|
||||
const VALID_CHARS = '[^' + TRIGGERS + PUNC + '\\s]'
|
||||
|
||||
// Non-standard series of chars. Each series must be preceded and followed by
|
||||
// a valid char.
|
||||
const VALID_JOINS =
|
||||
'(?:' +
|
||||
'\\.[ |$]|' + // E.g. "r. " in "Mr. Smith"
|
||||
' |' + // E.g. " " in "Josh Duck"
|
||||
'[' +
|
||||
PUNC +
|
||||
']|' + // E.g. "-' in "Salier-Hellendag"
|
||||
')'
|
||||
|
||||
const LENGTH_LIMIT = 75
|
||||
|
||||
const AtSignMentionsRegex = new RegExp(
|
||||
`(^|\\s|\\()([${TRIGGERS}]((?:${VALID_CHARS}${VALID_JOINS}){0,${LENGTH_LIMIT}}))$`,
|
||||
)
|
||||
|
||||
// 50 is the longest alias length limit.
|
||||
const ALIAS_LENGTH_LIMIT = 50
|
||||
|
||||
// Regex used to match alias.
|
||||
const AtSignMentionsRegexAliasRegex = new RegExp(
|
||||
`(^|\\s|\\()([${TRIGGERS}]((?:${VALID_CHARS}){0,${ALIAS_LENGTH_LIMIT}}))$`,
|
||||
)
|
||||
|
||||
// At most, 20 suggestions are shown in the popup.
|
||||
const SUGGESTION_LIST_LENGTH_LIMIT = 20
|
||||
|
||||
function checkForAtSignMentions(
|
||||
text: string,
|
||||
minMatchLength: number,
|
||||
): MenuTextMatch | null {
|
||||
let match = AtSignMentionsRegex.exec(text)
|
||||
|
||||
if (match === null) {
|
||||
match = AtSignMentionsRegexAliasRegex.exec(text)
|
||||
}
|
||||
if (match !== null) {
|
||||
// The strategy ignores leading whitespace but we need to know it's
|
||||
// length to add it to the leadOffset
|
||||
const maybeLeadingWhitespace = match[1]
|
||||
|
||||
const matchingString = match[3]
|
||||
if (matchingString.length >= minMatchLength) {
|
||||
return {
|
||||
leadOffset: match.index + maybeLeadingWhitespace.length,
|
||||
matchingString,
|
||||
replaceableString: match[2],
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getPossibleQueryMatch(text: string): MenuTextMatch | null {
|
||||
return checkForAtSignMentions(text, 0)
|
||||
}
|
||||
|
||||
class MentionTypeaheadOption extends MenuOption {
|
||||
name: string
|
||||
mentionable: Mentionable
|
||||
icon: React.ReactNode
|
||||
|
||||
constructor(result: SearchableMentionable) {
|
||||
switch (result.type) {
|
||||
case 'file':
|
||||
super(result.file.path)
|
||||
this.name = result.file.name
|
||||
this.mentionable = result
|
||||
break
|
||||
case 'folder':
|
||||
super(result.folder.path)
|
||||
this.name = result.folder.name
|
||||
this.mentionable = result
|
||||
break
|
||||
case 'vault':
|
||||
super('vault')
|
||||
this.name = 'Vault'
|
||||
this.mentionable = result
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function MentionsTypeaheadMenuItem({
|
||||
index,
|
||||
isSelected,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
option,
|
||||
}: {
|
||||
index: number
|
||||
isSelected: boolean
|
||||
onClick: () => void
|
||||
onMouseEnter: () => void
|
||||
option: MentionTypeaheadOption
|
||||
}) {
|
||||
let className = 'item'
|
||||
if (isSelected) {
|
||||
className += ' selected'
|
||||
}
|
||||
|
||||
const Icon = getMentionableIcon(option.mentionable)
|
||||
return (
|
||||
<li
|
||||
key={option.key}
|
||||
tabIndex={-1}
|
||||
className={className}
|
||||
ref={(el) => option.setRefElement(el)}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
id={`typeahead-item-${index}`}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onClick={onClick}
|
||||
>
|
||||
{Icon && <Icon size={14} className="infio-popover-item-icon" />}
|
||||
<span className="text">{option.name}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NewMentionsPlugin({
|
||||
searchResultByQuery,
|
||||
}: {
|
||||
searchResultByQuery: (query: string) => SearchableMentionable[]
|
||||
}): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
const [queryString, setQueryString] = useState<string | null>(null)
|
||||
|
||||
const results = useMemo(() => {
|
||||
if (queryString == null) return []
|
||||
return searchResultByQuery(queryString)
|
||||
}, [queryString, searchResultByQuery])
|
||||
|
||||
const checkForSlashTriggerMatch = useBasicTypeaheadTriggerMatch('/', {
|
||||
minLength: 0,
|
||||
})
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
results
|
||||
.map((result) => new MentionTypeaheadOption(result))
|
||||
.slice(0, SUGGESTION_LIST_LENGTH_LIMIT),
|
||||
[results],
|
||||
)
|
||||
|
||||
const onSelectOption = useCallback(
|
||||
(
|
||||
selectedOption: MentionTypeaheadOption,
|
||||
nodeToReplace: TextNode | null,
|
||||
closeMenu: () => void,
|
||||
) => {
|
||||
editor.update(() => {
|
||||
const mentionNode = $createMentionNode(
|
||||
getMentionableName(selectedOption.mentionable),
|
||||
serializeMentionable(selectedOption.mentionable),
|
||||
)
|
||||
if (nodeToReplace) {
|
||||
nodeToReplace.replace(mentionNode)
|
||||
}
|
||||
|
||||
const spaceNode = $createTextNode(' ')
|
||||
mentionNode.insertAfter(spaceNode)
|
||||
|
||||
spaceNode.select()
|
||||
closeMenu()
|
||||
})
|
||||
},
|
||||
[editor],
|
||||
)
|
||||
|
||||
const checkForMentionMatch = useCallback(
|
||||
(text: string) => {
|
||||
const slashMatch = checkForSlashTriggerMatch(text, editor)
|
||||
|
||||
if (slashMatch !== null) {
|
||||
return null
|
||||
}
|
||||
return getPossibleQueryMatch(text)
|
||||
},
|
||||
[checkForSlashTriggerMatch, editor],
|
||||
)
|
||||
|
||||
return (
|
||||
<LexicalTypeaheadMenuPlugin<MentionTypeaheadOption>
|
||||
onQueryChange={setQueryString}
|
||||
onSelectOption={onSelectOption}
|
||||
triggerFn={checkForMentionMatch}
|
||||
options={options}
|
||||
commandPriority={COMMAND_PRIORITY_NORMAL}
|
||||
menuRenderFn={(
|
||||
anchorElementRef,
|
||||
{ selectedIndex, selectOptionAndCleanUp, setHighlightedIndex },
|
||||
) =>
|
||||
anchorElementRef.current && results.length
|
||||
? createPortal(
|
||||
<div
|
||||
className="infio-popover"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
}}
|
||||
>
|
||||
<ul>
|
||||
{options.map((option, i: number) => (
|
||||
<MentionsTypeaheadMenuItem
|
||||
index={i}
|
||||
isSelected={selectedIndex === i}
|
||||
onClick={() => {
|
||||
setHighlightedIndex(i)
|
||||
selectOptionAndCleanUp(option)
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setHighlightedIndex(i)
|
||||
}}
|
||||
key={option.key}
|
||||
option={option}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>,
|
||||
anchorElementRef.current,
|
||||
)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { TextNode } from 'lexical'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export default function NoFormatPlugin() {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
editor.registerNodeTransform(TextNode, (node) => {
|
||||
if (node.getFormat() !== 0) {
|
||||
node.setFormat(0)
|
||||
}
|
||||
})
|
||||
}, [editor])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { COMMAND_PRIORITY_LOW, KEY_ENTER_COMMAND } from 'lexical'
|
||||
import { Platform } from 'obsidian'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export default function OnEnterPlugin({
|
||||
onEnter,
|
||||
onVaultChat,
|
||||
}: {
|
||||
onEnter: (evt: KeyboardEvent) => void
|
||||
onVaultChat?: () => void
|
||||
}) {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
const removeListener = editor.registerCommand(
|
||||
KEY_ENTER_COMMAND,
|
||||
(evt: KeyboardEvent) => {
|
||||
console.log('onEnter', evt)
|
||||
if (
|
||||
onVaultChat &&
|
||||
(Platform.isMacOS ? evt.metaKey : evt.ctrlKey)
|
||||
) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
onVaultChat()
|
||||
return true
|
||||
}
|
||||
if (evt.shiftKey) {
|
||||
return false
|
||||
}
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
onEnter(evt)
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
)
|
||||
|
||||
return () => {
|
||||
removeListener()
|
||||
}
|
||||
}, [editor, onEnter, onVaultChat])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { Klass, LexicalNode, NodeKey, NodeMutation } from 'lexical'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export type NodeMutations<T> = Map<NodeKey, { mutation: NodeMutation; node: T }>
|
||||
|
||||
export default function OnMutationPlugin<T extends LexicalNode>({
|
||||
nodeClass,
|
||||
onMutation,
|
||||
}: {
|
||||
nodeClass: Klass<T>
|
||||
onMutation: (mutations: NodeMutations<T>) => void
|
||||
}) {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
const removeListener = editor.registerMutationListener(
|
||||
nodeClass,
|
||||
(mutatedNodes, payload) => {
|
||||
const editorState = editor.getEditorState()
|
||||
|
||||
const mutations = new Map<
|
||||
NodeKey,
|
||||
{ mutation: NodeMutation; node: T }
|
||||
>()
|
||||
for (const [key, mutation] of mutatedNodes) {
|
||||
mutations.set(key, {
|
||||
mutation,
|
||||
node:
|
||||
mutation === 'destroyed'
|
||||
? (payload.prevEditorState._nodeMap.get(key) as T)
|
||||
: (editorState._nodeMap.get(key) as T),
|
||||
})
|
||||
}
|
||||
|
||||
onMutation(mutations)
|
||||
},
|
||||
)
|
||||
|
||||
return () => {
|
||||
removeListener()
|
||||
}
|
||||
}, [editor, nodeClass, onMutation])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license.
|
||||
* Original source: https://github.com/facebook/lexical
|
||||
*
|
||||
* Modified from the original code
|
||||
* - Added custom positioning logic for menu placement
|
||||
*/
|
||||
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import {
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
CommandListenerPriority,
|
||||
KEY_ARROW_DOWN_COMMAND,
|
||||
KEY_ARROW_UP_COMMAND,
|
||||
KEY_ENTER_COMMAND,
|
||||
KEY_ESCAPE_COMMAND,
|
||||
KEY_TAB_COMMAND,
|
||||
LexicalCommand,
|
||||
LexicalEditor,
|
||||
TextNode,
|
||||
createCommand,
|
||||
} from 'lexical'
|
||||
import {
|
||||
MutableRefObject,
|
||||
ReactPortal,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
export type MenuTextMatch = {
|
||||
leadOffset: number
|
||||
matchingString: string
|
||||
replaceableString: string
|
||||
}
|
||||
|
||||
export type MenuResolution = {
|
||||
match?: MenuTextMatch
|
||||
getRect: () => DOMRect
|
||||
}
|
||||
|
||||
export const PUNCTUATION =
|
||||
'\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;'
|
||||
|
||||
export class MenuOption {
|
||||
key: string
|
||||
ref?: MutableRefObject<HTMLElement | null>
|
||||
|
||||
constructor(key: string) {
|
||||
this.key = key
|
||||
this.ref = { current: null }
|
||||
this.setRefElement = this.setRefElement.bind(this)
|
||||
}
|
||||
|
||||
setRefElement(element: HTMLElement | null) {
|
||||
this.ref = { current: element }
|
||||
}
|
||||
}
|
||||
|
||||
export type MenuRenderFn<TOption extends MenuOption> = (
|
||||
anchorElementRef: MutableRefObject<HTMLElement | null>,
|
||||
itemProps: {
|
||||
selectedIndex: number | null
|
||||
selectOptionAndCleanUp: (option: TOption) => void
|
||||
setHighlightedIndex: (index: number) => void
|
||||
options: TOption[]
|
||||
},
|
||||
matchingString: string | null,
|
||||
) => ReactPortal | JSX.Element | null
|
||||
|
||||
const scrollIntoViewIfNeeded = (target: HTMLElement) => {
|
||||
const typeaheadContainerNode = document.getElementById('typeahead-menu')
|
||||
if (!typeaheadContainerNode) {
|
||||
return
|
||||
}
|
||||
|
||||
const typeaheadRect = typeaheadContainerNode.getBoundingClientRect()
|
||||
|
||||
if (typeaheadRect.top + typeaheadRect.height > window.innerHeight) {
|
||||
typeaheadContainerNode.scrollIntoView({
|
||||
block: 'center',
|
||||
})
|
||||
}
|
||||
|
||||
if (typeaheadRect.top < 0) {
|
||||
typeaheadContainerNode.scrollIntoView({
|
||||
block: 'center',
|
||||
})
|
||||
}
|
||||
|
||||
target.scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk backwards along user input and forward through entity title to try
|
||||
* and replace more of the user's text with entity.
|
||||
*/
|
||||
function getFullMatchOffset(
|
||||
documentText: string,
|
||||
entryText: string,
|
||||
offset: number,
|
||||
): number {
|
||||
let triggerOffset = offset
|
||||
for (let i = triggerOffset; i <= entryText.length; i++) {
|
||||
if (documentText.substr(-i) === entryText.substr(0, i)) {
|
||||
triggerOffset = i
|
||||
}
|
||||
}
|
||||
return triggerOffset
|
||||
}
|
||||
|
||||
/**
|
||||
* Split Lexical TextNode and return a new TextNode only containing matched text.
|
||||
* Common use cases include: removing the node, replacing with a new node.
|
||||
*/
|
||||
function $splitNodeContainingQuery(match: MenuTextMatch): TextNode | null {
|
||||
const selection = $getSelection()
|
||||
if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
|
||||
return null
|
||||
}
|
||||
const anchor = selection.anchor
|
||||
if (anchor.type !== 'text') {
|
||||
return null
|
||||
}
|
||||
const anchorNode = anchor.getNode()
|
||||
if (!anchorNode.isSimpleText()) {
|
||||
return null
|
||||
}
|
||||
const selectionOffset = anchor.offset
|
||||
const textContent = anchorNode.getTextContent().slice(0, selectionOffset)
|
||||
const characterOffset = match.replaceableString.length
|
||||
const queryOffset = getFullMatchOffset(
|
||||
textContent,
|
||||
match.matchingString,
|
||||
characterOffset,
|
||||
)
|
||||
const startOffset = selectionOffset - queryOffset
|
||||
if (startOffset < 0) {
|
||||
return null
|
||||
}
|
||||
let newNode
|
||||
if (startOffset === 0) {
|
||||
;[newNode] = anchorNode.splitText(selectionOffset)
|
||||
} else {
|
||||
;[, newNode] = anchorNode.splitText(startOffset, selectionOffset)
|
||||
}
|
||||
|
||||
return newNode
|
||||
}
|
||||
|
||||
// Got from https://stackoverflow.com/a/42543908/2013580
|
||||
export function getScrollParent(
|
||||
element: HTMLElement,
|
||||
includeHidden: boolean,
|
||||
): HTMLElement | HTMLBodyElement {
|
||||
let style = getComputedStyle(element)
|
||||
const excludeStaticParent = style.position === 'absolute'
|
||||
const overflowRegex = includeHidden ? /(auto|scroll|hidden)/ : /(auto|scroll)/
|
||||
if (style.position === 'fixed') {
|
||||
return document.body
|
||||
}
|
||||
for (
|
||||
let parent: HTMLElement | null = element;
|
||||
(parent = parent.parentElement);
|
||||
|
||||
) {
|
||||
style = getComputedStyle(parent)
|
||||
if (excludeStaticParent && style.position === 'static') {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
overflowRegex.test(style.overflow + style.overflowY + style.overflowX)
|
||||
) {
|
||||
return parent
|
||||
}
|
||||
}
|
||||
return document.body
|
||||
}
|
||||
|
||||
function isTriggerVisibleInNearestScrollContainer(
|
||||
targetElement: HTMLElement,
|
||||
containerElement: HTMLElement,
|
||||
): boolean {
|
||||
const tRect = targetElement.getBoundingClientRect()
|
||||
const cRect = containerElement.getBoundingClientRect()
|
||||
return tRect.top > cRect.top && tRect.top < cRect.bottom
|
||||
}
|
||||
|
||||
// Reposition the menu on scroll, window resize, and element resize.
|
||||
export function useDynamicPositioning(
|
||||
resolution: MenuResolution | null,
|
||||
targetElement: HTMLElement | null,
|
||||
onReposition: () => void,
|
||||
onVisibilityChange?: (isInView: boolean) => void,
|
||||
) {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
useEffect(() => {
|
||||
if (targetElement != null && resolution != null) {
|
||||
const rootElement = editor.getRootElement()
|
||||
const rootScrollParent =
|
||||
rootElement != null
|
||||
? getScrollParent(rootElement, false)
|
||||
: document.body
|
||||
let ticking = false
|
||||
let previousIsInView = isTriggerVisibleInNearestScrollContainer(
|
||||
targetElement,
|
||||
rootScrollParent,
|
||||
)
|
||||
const handleScroll = function () {
|
||||
if (!ticking) {
|
||||
window.requestAnimationFrame(function () {
|
||||
onReposition()
|
||||
ticking = false
|
||||
})
|
||||
ticking = true
|
||||
}
|
||||
const isInView = isTriggerVisibleInNearestScrollContainer(
|
||||
targetElement,
|
||||
rootScrollParent,
|
||||
)
|
||||
if (isInView !== previousIsInView) {
|
||||
previousIsInView = isInView
|
||||
if (onVisibilityChange != null) {
|
||||
onVisibilityChange(isInView)
|
||||
}
|
||||
}
|
||||
}
|
||||
const resizeObserver = new ResizeObserver(onReposition)
|
||||
window.addEventListener('resize', onReposition)
|
||||
document.addEventListener('scroll', handleScroll, {
|
||||
capture: true,
|
||||
passive: true,
|
||||
})
|
||||
resizeObserver.observe(targetElement)
|
||||
return () => {
|
||||
resizeObserver.unobserve(targetElement)
|
||||
window.removeEventListener('resize', onReposition)
|
||||
document.removeEventListener('scroll', handleScroll, true)
|
||||
}
|
||||
}
|
||||
}, [targetElement, editor, onVisibilityChange, onReposition, resolution])
|
||||
}
|
||||
|
||||
export const SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND: LexicalCommand<{
|
||||
index: number
|
||||
option: MenuOption
|
||||
}> = createCommand('SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND')
|
||||
|
||||
export function LexicalMenu<TOption extends MenuOption>({
|
||||
close,
|
||||
editor,
|
||||
anchorElementRef,
|
||||
resolution,
|
||||
options,
|
||||
menuRenderFn,
|
||||
onSelectOption,
|
||||
shouldSplitNodeWithQuery = false,
|
||||
commandPriority = COMMAND_PRIORITY_LOW,
|
||||
}: {
|
||||
close: () => void
|
||||
editor: LexicalEditor
|
||||
anchorElementRef: MutableRefObject<HTMLElement>
|
||||
resolution: MenuResolution
|
||||
options: TOption[]
|
||||
shouldSplitNodeWithQuery?: boolean
|
||||
menuRenderFn: MenuRenderFn<TOption>
|
||||
onSelectOption: (
|
||||
option: TOption,
|
||||
textNodeContainingQuery: TextNode | null,
|
||||
closeMenu: () => void,
|
||||
matchingString: string,
|
||||
) => void
|
||||
commandPriority?: CommandListenerPriority
|
||||
}): JSX.Element | null {
|
||||
const [selectedIndex, setHighlightedIndex] = useState<null | number>(null)
|
||||
|
||||
const matchingString = resolution.match?.matchingString
|
||||
|
||||
useEffect(() => {
|
||||
setHighlightedIndex(0)
|
||||
}, [matchingString])
|
||||
|
||||
const selectOptionAndCleanUp = useCallback(
|
||||
(selectedEntry: TOption) => {
|
||||
editor.update(() => {
|
||||
const textNodeContainingQuery =
|
||||
resolution.match != null && shouldSplitNodeWithQuery
|
||||
? $splitNodeContainingQuery(resolution.match)
|
||||
: null
|
||||
|
||||
onSelectOption(
|
||||
selectedEntry,
|
||||
textNodeContainingQuery,
|
||||
close,
|
||||
resolution.match ? resolution.match.matchingString : '',
|
||||
)
|
||||
})
|
||||
},
|
||||
[editor, shouldSplitNodeWithQuery, resolution.match, onSelectOption, close],
|
||||
)
|
||||
|
||||
const updateSelectedIndex = useCallback(
|
||||
(index: number) => {
|
||||
const rootElem = editor.getRootElement()
|
||||
if (rootElem !== null) {
|
||||
rootElem.setAttribute(
|
||||
'aria-activedescendant',
|
||||
`typeahead-item-${index}`,
|
||||
)
|
||||
setHighlightedIndex(index)
|
||||
}
|
||||
},
|
||||
[editor],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const rootElem = editor.getRootElement()
|
||||
if (rootElem !== null) {
|
||||
rootElem.removeAttribute('aria-activedescendant')
|
||||
}
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (options === null) {
|
||||
setHighlightedIndex(null)
|
||||
} else if (selectedIndex === null) {
|
||||
updateSelectedIndex(0)
|
||||
}
|
||||
}, [options, selectedIndex, updateSelectedIndex])
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerCommand(
|
||||
SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND,
|
||||
({ option }) => {
|
||||
if (option.ref?.current != null) {
|
||||
scrollIntoViewIfNeeded(option.ref.current)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
commandPriority,
|
||||
),
|
||||
)
|
||||
}, [editor, updateSelectedIndex, commandPriority])
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerCommand<KeyboardEvent>(
|
||||
KEY_ARROW_DOWN_COMMAND,
|
||||
(payload) => {
|
||||
const event = payload
|
||||
if (options?.length && selectedIndex !== null) {
|
||||
const newSelectedIndex =
|
||||
selectedIndex !== options.length - 1 ? selectedIndex + 1 : 0
|
||||
updateSelectedIndex(newSelectedIndex)
|
||||
const option = options[newSelectedIndex]
|
||||
if (option.ref?.current != null) {
|
||||
editor.dispatchCommand(
|
||||
SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND,
|
||||
{
|
||||
index: newSelectedIndex,
|
||||
option,
|
||||
},
|
||||
)
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
return true
|
||||
},
|
||||
commandPriority,
|
||||
),
|
||||
editor.registerCommand<KeyboardEvent>(
|
||||
KEY_ARROW_UP_COMMAND,
|
||||
(payload) => {
|
||||
const event = payload
|
||||
if (options?.length && selectedIndex !== null) {
|
||||
const newSelectedIndex =
|
||||
selectedIndex !== 0 ? selectedIndex - 1 : options.length - 1
|
||||
updateSelectedIndex(newSelectedIndex)
|
||||
const option = options[newSelectedIndex]
|
||||
if (option.ref?.current != null) {
|
||||
scrollIntoViewIfNeeded(option.ref.current)
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
return true
|
||||
},
|
||||
commandPriority,
|
||||
),
|
||||
editor.registerCommand<KeyboardEvent>(
|
||||
KEY_ESCAPE_COMMAND,
|
||||
(payload) => {
|
||||
const event = payload
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
close()
|
||||
return true
|
||||
},
|
||||
commandPriority,
|
||||
),
|
||||
editor.registerCommand<KeyboardEvent>(
|
||||
KEY_TAB_COMMAND,
|
||||
(payload) => {
|
||||
const event = payload
|
||||
if (
|
||||
options === null ||
|
||||
selectedIndex === null ||
|
||||
options[selectedIndex] == null
|
||||
) {
|
||||
return false
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
selectOptionAndCleanUp(options[selectedIndex])
|
||||
return true
|
||||
},
|
||||
commandPriority,
|
||||
),
|
||||
editor.registerCommand(
|
||||
KEY_ENTER_COMMAND,
|
||||
(event: KeyboardEvent | null) => {
|
||||
if (
|
||||
options === null ||
|
||||
selectedIndex === null ||
|
||||
options[selectedIndex] == null
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (event !== null) {
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
selectOptionAndCleanUp(options[selectedIndex])
|
||||
return true
|
||||
},
|
||||
commandPriority,
|
||||
),
|
||||
)
|
||||
}, [
|
||||
selectOptionAndCleanUp,
|
||||
close,
|
||||
editor,
|
||||
options,
|
||||
selectedIndex,
|
||||
updateSelectedIndex,
|
||||
commandPriority,
|
||||
])
|
||||
|
||||
const listItemProps = useMemo(
|
||||
() => ({
|
||||
options,
|
||||
selectOptionAndCleanUp,
|
||||
selectedIndex,
|
||||
setHighlightedIndex,
|
||||
}),
|
||||
[selectOptionAndCleanUp, selectedIndex, options],
|
||||
)
|
||||
|
||||
return menuRenderFn(
|
||||
anchorElementRef,
|
||||
listItemProps,
|
||||
resolution.match ? resolution.match.matchingString : '',
|
||||
)
|
||||
}
|
||||
|
||||
export function useMenuAnchorRef(
|
||||
resolution: MenuResolution | null,
|
||||
setResolution: (r: MenuResolution | null) => void,
|
||||
className?: string,
|
||||
parent: HTMLElement = document.body,
|
||||
shouldIncludePageYOffset__EXPERIMENTAL = true,
|
||||
): MutableRefObject<HTMLElement> {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const anchorElementRef = useRef<HTMLElement>(document.createElement('div'))
|
||||
const positionMenu = useCallback(() => {
|
||||
anchorElementRef.current.style.top = anchorElementRef.current.style.bottom
|
||||
const rootElement = editor.getRootElement()
|
||||
const containerDiv = anchorElementRef.current
|
||||
|
||||
const menuEle = containerDiv.firstChild as HTMLElement
|
||||
if (rootElement !== null && resolution !== null) {
|
||||
const { left, top, width, height } = resolution.getRect()
|
||||
const anchorHeight = anchorElementRef.current.offsetHeight // use to position under anchor
|
||||
containerDiv.style.top = `${top +
|
||||
anchorHeight +
|
||||
3 +
|
||||
(shouldIncludePageYOffset__EXPERIMENTAL ? window.pageYOffset : 0)
|
||||
}px`
|
||||
containerDiv.style.left = `${left + window.pageXOffset}px`
|
||||
containerDiv.style.height = `${height}px`
|
||||
containerDiv.style.width = `${width}px`
|
||||
if (menuEle !== null) {
|
||||
menuEle.style.top = `${top}`
|
||||
const menuRect = menuEle.getBoundingClientRect()
|
||||
const menuHeight = menuRect.height
|
||||
const menuWidth = menuRect.width
|
||||
|
||||
const rootElementRect = rootElement.getBoundingClientRect()
|
||||
|
||||
if (left + menuWidth > rootElementRect.right) {
|
||||
containerDiv.style.left = `${rootElementRect.right - menuWidth + window.pageXOffset
|
||||
}px`
|
||||
}
|
||||
if (
|
||||
// If it exceeds the window height, it should always be displayed above, but the original code checks if it doesn't exceed the editor's top as well. So I modified it.
|
||||
// (top + menuHeight > window.innerHeight ||
|
||||
// top + menuHeight > rootElementRect.bottom) &&
|
||||
// top - rootElementRect.top > menuHeight + height
|
||||
top + menuHeight >
|
||||
window.innerHeight
|
||||
) {
|
||||
containerDiv.style.top = `${top -
|
||||
menuHeight -
|
||||
height +
|
||||
(shouldIncludePageYOffset__EXPERIMENTAL ? window.pageYOffset : 0)
|
||||
}px`
|
||||
}
|
||||
}
|
||||
|
||||
if (!containerDiv.isConnected) {
|
||||
if (className != null) {
|
||||
containerDiv.className = className
|
||||
}
|
||||
containerDiv.setAttribute('aria-label', 'Typeahead menu')
|
||||
containerDiv.setAttribute('id', 'typeahead-menu')
|
||||
containerDiv.setAttribute('role', 'listbox')
|
||||
containerDiv.style.display = 'block'
|
||||
containerDiv.style.position = 'absolute'
|
||||
parent.append(containerDiv)
|
||||
}
|
||||
anchorElementRef.current = containerDiv
|
||||
rootElement.setAttribute('aria-controls', 'typeahead-menu')
|
||||
}
|
||||
}, [
|
||||
editor,
|
||||
resolution,
|
||||
shouldIncludePageYOffset__EXPERIMENTAL,
|
||||
className,
|
||||
parent,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const rootElement = editor.getRootElement()
|
||||
if (resolution !== null) {
|
||||
positionMenu()
|
||||
return () => {
|
||||
if (rootElement !== null) {
|
||||
rootElement.removeAttribute('aria-controls')
|
||||
}
|
||||
|
||||
const containerDiv = anchorElementRef.current
|
||||
if (containerDiv?.isConnected) {
|
||||
containerDiv.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [editor, positionMenu, resolution])
|
||||
|
||||
const onVisibilityChange = useCallback(
|
||||
(isInView: boolean) => {
|
||||
if (resolution !== null) {
|
||||
if (!isInView) {
|
||||
setResolution(null)
|
||||
}
|
||||
}
|
||||
},
|
||||
[resolution, setResolution],
|
||||
)
|
||||
|
||||
useDynamicPositioning(
|
||||
resolution,
|
||||
anchorElementRef.current,
|
||||
positionMenu,
|
||||
onVisibilityChange,
|
||||
)
|
||||
|
||||
return anchorElementRef
|
||||
}
|
||||
|
||||
export type TriggerFn = (
|
||||
text: string,
|
||||
editor: LexicalEditor,
|
||||
) => MenuTextMatch | null
|
||||
@@ -0,0 +1,146 @@
|
||||
import { $generateJSONFromSelectedNodes } from '@lexical/clipboard'
|
||||
import { BaseSerializedNode } from '@lexical/clipboard/clipboard'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import * as Dialog from '@radix-ui/react-dialog'
|
||||
import {
|
||||
$getSelection,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
} from 'lexical'
|
||||
import { CSSProperties, useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import CreateTemplateDialogContent from '../../../CreateTemplateDialog'
|
||||
|
||||
export default function CreateTemplatePopoverPlugin({
|
||||
anchorElement,
|
||||
contentEditableElement,
|
||||
}: {
|
||||
anchorElement: HTMLElement | null
|
||||
contentEditableElement: HTMLElement | null
|
||||
}): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
const [popoverStyle, setPopoverStyle] = useState<CSSProperties | null>(null)
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [selectedSerializedNodes, setSelectedSerializedNodes] = useState<
|
||||
BaseSerializedNode[] | null
|
||||
>(null)
|
||||
|
||||
const popoverRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const getSelectedSerializedNodes = useCallback(():
|
||||
| BaseSerializedNode[]
|
||||
| null => {
|
||||
if (!editor) return null
|
||||
let selectedNodes: BaseSerializedNode[] | null = null
|
||||
editor.update(() => {
|
||||
const selection = $getSelection()
|
||||
if (!selection) return
|
||||
selectedNodes = $generateJSONFromSelectedNodes(editor, selection).nodes
|
||||
if (selectedNodes.length === 0) return null
|
||||
})
|
||||
return selectedNodes
|
||||
}, [editor])
|
||||
|
||||
const updatePopoverPosition = useCallback(() => {
|
||||
if (!anchorElement || !contentEditableElement) return
|
||||
const nativeSelection = document.getSelection()
|
||||
const range = nativeSelection?.getRangeAt(0)
|
||||
if (!range || range.collapsed) {
|
||||
setIsPopoverOpen(false)
|
||||
return
|
||||
}
|
||||
if (!contentEditableElement.contains(range.commonAncestorContainer)) {
|
||||
setIsPopoverOpen(false)
|
||||
return
|
||||
}
|
||||
const rects = Array.from(range.getClientRects())
|
||||
if (rects.length === 0) {
|
||||
setIsPopoverOpen(false)
|
||||
return
|
||||
}
|
||||
const anchorRect = anchorElement.getBoundingClientRect()
|
||||
const idealLeft = rects[rects.length - 1].right - anchorRect.left
|
||||
const paddingX = 8
|
||||
const paddingY = 4
|
||||
const minLeft = (popoverRef.current?.offsetWidth ?? 0) + paddingX
|
||||
const finalLeft = Math.max(minLeft, idealLeft)
|
||||
setPopoverStyle({
|
||||
top: rects[rects.length - 1].bottom - anchorRect.top + paddingY,
|
||||
left: finalLeft,
|
||||
transform: 'translate(-100%, 0)',
|
||||
})
|
||||
setIsPopoverOpen(true)
|
||||
}, [anchorElement, contentEditableElement])
|
||||
|
||||
useEffect(() => {
|
||||
const removeSelectionChangeListener = editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
() => {
|
||||
updatePopoverPosition()
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
)
|
||||
return () => {
|
||||
removeSelectionChangeListener()
|
||||
}
|
||||
}, [editor, updatePopoverPosition])
|
||||
|
||||
useEffect(() => {
|
||||
// Update popover position when the content is cleared
|
||||
// (Selection change event doesn't fire in this case)
|
||||
if (!isPopoverOpen) return
|
||||
const removeTextContentChangeListener = editor.registerTextContentListener(
|
||||
() => {
|
||||
updatePopoverPosition()
|
||||
},
|
||||
)
|
||||
return () => {
|
||||
removeTextContentChangeListener()
|
||||
}
|
||||
}, [editor, isPopoverOpen, updatePopoverPosition])
|
||||
|
||||
useEffect(() => {
|
||||
if (!contentEditableElement) return
|
||||
const handleScroll = () => {
|
||||
updatePopoverPosition()
|
||||
}
|
||||
contentEditableElement.addEventListener('scroll', handleScroll)
|
||||
return () => {
|
||||
contentEditableElement.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
}, [contentEditableElement, updatePopoverPosition])
|
||||
|
||||
return (
|
||||
<Dialog.Root
|
||||
modal={false}
|
||||
open={isDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
setSelectedSerializedNodes(getSelectedSerializedNodes())
|
||||
}
|
||||
setIsDialogOpen(open)
|
||||
setIsPopoverOpen(false)
|
||||
}}
|
||||
>
|
||||
<Dialog.Trigger asChild>
|
||||
<button
|
||||
ref={popoverRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
visibility: isPopoverOpen ? 'visible' : 'hidden',
|
||||
...popoverStyle,
|
||||
}}
|
||||
>
|
||||
Create template
|
||||
</button>
|
||||
</Dialog.Trigger>
|
||||
<CreateTemplateDialogContent
|
||||
selectedSerializedNodes={selectedSerializedNodes}
|
||||
onClose={() => setIsDialogOpen(false)}
|
||||
/>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
$parseSerializedNode,
|
||||
COMMAND_PRIORITY_NORMAL,
|
||||
TextNode,
|
||||
} from 'lexical'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import { useDatabase } from '../../../../../contexts/DatabaseContext'
|
||||
import { SelectTemplate } from '../../../../../database/schema'
|
||||
import { MenuOption } from '../shared/LexicalMenu'
|
||||
import {
|
||||
LexicalTypeaheadMenuPlugin,
|
||||
useBasicTypeaheadTriggerMatch,
|
||||
} from '../typeahead-menu/LexicalTypeaheadMenuPlugin'
|
||||
|
||||
class TemplateTypeaheadOption extends MenuOption {
|
||||
name: string
|
||||
template: SelectTemplate
|
||||
|
||||
constructor(name: string, template: SelectTemplate) {
|
||||
super(name)
|
||||
this.name = name
|
||||
this.template = template
|
||||
}
|
||||
}
|
||||
|
||||
function TemplateMenuItem({
|
||||
index,
|
||||
isSelected,
|
||||
onClick,
|
||||
onDelete,
|
||||
onMouseEnter,
|
||||
option,
|
||||
}: {
|
||||
index: number
|
||||
isSelected: boolean
|
||||
onClick: () => void
|
||||
onDelete: () => void
|
||||
onMouseEnter: () => void
|
||||
option: TemplateTypeaheadOption
|
||||
}) {
|
||||
return (
|
||||
<li
|
||||
key={option.key}
|
||||
tabIndex={-1}
|
||||
className={clsx('item', isSelected && 'selected')}
|
||||
ref={(el) => option.setRefElement(el)}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
id={`typeahead-item-${index}`}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="infio-chat-template-menu-item">
|
||||
<div className="text">{option.name}</div>
|
||||
<div
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation()
|
||||
evt.preventDefault()
|
||||
onDelete()
|
||||
}}
|
||||
className="infio-chat-template-menu-item-delete"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TemplatePlugin() {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const { getTemplateManager } = useDatabase()
|
||||
|
||||
const [queryString, setQueryString] = useState<string | null>(null)
|
||||
const [searchResults, setSearchResults] = useState<SelectTemplate[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (queryString == null) return
|
||||
getTemplateManager().then((templateManager) =>
|
||||
templateManager.searchTemplates(queryString).then(setSearchResults),
|
||||
)
|
||||
}, [queryString, getTemplateManager])
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
searchResults.map(
|
||||
(result) => new TemplateTypeaheadOption(result.name, result),
|
||||
),
|
||||
[searchResults],
|
||||
)
|
||||
|
||||
const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', {
|
||||
minLength: 0,
|
||||
})
|
||||
|
||||
const onSelectOption = useCallback(
|
||||
(
|
||||
selectedOption: TemplateTypeaheadOption,
|
||||
nodeToRemove: TextNode | null,
|
||||
closeMenu: () => void,
|
||||
) => {
|
||||
editor.update(() => {
|
||||
const parsedNodes = selectedOption.template.content.nodes.map((node) =>
|
||||
$parseSerializedNode(node),
|
||||
)
|
||||
if (nodeToRemove) {
|
||||
const parent = nodeToRemove.getParentOrThrow()
|
||||
parent.splice(nodeToRemove.getIndexWithinParent(), 1, parsedNodes)
|
||||
const lastNode = parsedNodes[parsedNodes.length - 1]
|
||||
lastNode.selectEnd()
|
||||
}
|
||||
closeMenu()
|
||||
})
|
||||
},
|
||||
[editor],
|
||||
)
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (option: TemplateTypeaheadOption) => {
|
||||
await (await getTemplateManager()).deleteTemplate(option.template.id)
|
||||
if (queryString !== null) {
|
||||
const updatedResults = await (
|
||||
await getTemplateManager()
|
||||
).searchTemplates(queryString)
|
||||
setSearchResults(updatedResults)
|
||||
}
|
||||
},
|
||||
[getTemplateManager, queryString],
|
||||
)
|
||||
|
||||
return (
|
||||
<LexicalTypeaheadMenuPlugin<TemplateTypeaheadOption>
|
||||
onQueryChange={setQueryString}
|
||||
onSelectOption={onSelectOption}
|
||||
triggerFn={checkForTriggerMatch}
|
||||
options={options}
|
||||
commandPriority={COMMAND_PRIORITY_NORMAL}
|
||||
menuRenderFn={(
|
||||
anchorElementRef,
|
||||
{ selectedIndex, selectOptionAndCleanUp, setHighlightedIndex },
|
||||
) =>
|
||||
anchorElementRef.current && searchResults.length
|
||||
? createPortal(
|
||||
<div
|
||||
className="infio-popover"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
}}
|
||||
>
|
||||
<ul>
|
||||
{options.map((option, i: number) => (
|
||||
<TemplateMenuItem
|
||||
index={i}
|
||||
isSelected={selectedIndex === i}
|
||||
onClick={() => {
|
||||
setHighlightedIndex(i)
|
||||
selectOptionAndCleanUp(option)
|
||||
}}
|
||||
onDelete={() => {
|
||||
handleDelete(option)
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setHighlightedIndex(i)
|
||||
}}
|
||||
key={option.key}
|
||||
option={option}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>,
|
||||
anchorElementRef.current,
|
||||
)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license.
|
||||
* Original source: https://github.com/facebook/lexical
|
||||
*
|
||||
* Modified from the original code
|
||||
*/
|
||||
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import {
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
$isTextNode,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
CommandListenerPriority,
|
||||
LexicalCommand,
|
||||
LexicalEditor,
|
||||
RangeSelection,
|
||||
TextNode,
|
||||
createCommand,
|
||||
} from 'lexical'
|
||||
import { startTransition, useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import {
|
||||
LexicalMenu,
|
||||
MenuOption,
|
||||
MenuRenderFn,
|
||||
MenuResolution,
|
||||
TriggerFn,
|
||||
useMenuAnchorRef,
|
||||
} from '../shared/LexicalMenu'
|
||||
|
||||
export const PUNCTUATION =
|
||||
'\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;'
|
||||
|
||||
function getTextUpToAnchor(selection: RangeSelection): string | null {
|
||||
const anchor = selection.anchor
|
||||
if (anchor.type !== 'text') {
|
||||
return null
|
||||
}
|
||||
const anchorNode = anchor.getNode()
|
||||
if (!anchorNode.isSimpleText()) {
|
||||
return null
|
||||
}
|
||||
const anchorOffset = anchor.offset
|
||||
return anchorNode.getTextContent().slice(0, anchorOffset)
|
||||
}
|
||||
|
||||
function tryToPositionRange(
|
||||
leadOffset: number,
|
||||
range: Range,
|
||||
editorWindow: Window,
|
||||
): boolean {
|
||||
const domSelection = editorWindow.getSelection()
|
||||
if (domSelection === null || !domSelection.isCollapsed) {
|
||||
return false
|
||||
}
|
||||
const anchorNode = domSelection.anchorNode
|
||||
const startOffset = leadOffset
|
||||
const endOffset = domSelection.anchorOffset
|
||||
|
||||
if (anchorNode == null || endOffset == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
range.setStart(anchorNode, startOffset)
|
||||
range.setEnd(anchorNode, endOffset)
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function getQueryTextForSearch(editor: LexicalEditor): string | null {
|
||||
let text = null
|
||||
editor.getEditorState().read(() => {
|
||||
const selection = $getSelection()
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return
|
||||
}
|
||||
text = getTextUpToAnchor(selection)
|
||||
})
|
||||
return text
|
||||
}
|
||||
|
||||
function isSelectionOnEntityBoundary(
|
||||
editor: LexicalEditor,
|
||||
offset: number,
|
||||
): boolean {
|
||||
if (offset !== 0) {
|
||||
return false
|
||||
}
|
||||
return editor.getEditorState().read(() => {
|
||||
const selection = $getSelection()
|
||||
if ($isRangeSelection(selection)) {
|
||||
const anchor = selection.anchor
|
||||
const anchorNode = anchor.getNode()
|
||||
const prevSibling = anchorNode.getPreviousSibling()
|
||||
return $isTextNode(prevSibling) && prevSibling.isTextEntity()
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
// Got from https://stackoverflow.com/a/42543908/2013580
|
||||
export function getScrollParent(
|
||||
element: HTMLElement,
|
||||
includeHidden: boolean,
|
||||
): HTMLElement | HTMLBodyElement {
|
||||
let style = getComputedStyle(element)
|
||||
const excludeStaticParent = style.position === 'absolute'
|
||||
const overflowRegex = includeHidden ? /(auto|scroll|hidden)/ : /(auto|scroll)/
|
||||
if (style.position === 'fixed') {
|
||||
return document.body
|
||||
}
|
||||
for (
|
||||
let parent: HTMLElement | null = element;
|
||||
(parent = parent.parentElement);
|
||||
|
||||
) {
|
||||
style = getComputedStyle(parent)
|
||||
if (excludeStaticParent && style.position === 'static') {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
overflowRegex.test(style.overflow + style.overflowY + style.overflowX)
|
||||
) {
|
||||
return parent
|
||||
}
|
||||
}
|
||||
return document.body
|
||||
}
|
||||
|
||||
export const SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND: LexicalCommand<{
|
||||
index: number
|
||||
option: MenuOption
|
||||
}> = createCommand('SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND')
|
||||
|
||||
export function useBasicTypeaheadTriggerMatch(
|
||||
trigger: string,
|
||||
{ minLength = 1, maxLength = 75 }: { minLength?: number; maxLength?: number },
|
||||
): TriggerFn {
|
||||
return useCallback(
|
||||
(text: string) => {
|
||||
const validChars = '[^' + trigger + PUNCTUATION + '\\s]'
|
||||
const TypeaheadTriggerRegex = new RegExp(
|
||||
`(^|\\s|\\()([${trigger}]((?:${validChars}){0,${maxLength}}))$`,
|
||||
)
|
||||
const match = TypeaheadTriggerRegex.exec(text)
|
||||
if (match !== null) {
|
||||
const maybeLeadingWhitespace = match[1]
|
||||
const matchingString = match[3]
|
||||
if (matchingString.length >= minLength) {
|
||||
return {
|
||||
leadOffset: match.index + maybeLeadingWhitespace.length,
|
||||
matchingString,
|
||||
replaceableString: match[2],
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
[maxLength, minLength, trigger],
|
||||
)
|
||||
}
|
||||
|
||||
export type TypeaheadMenuPluginProps<TOption extends MenuOption> = {
|
||||
onQueryChange: (matchingString: string | null) => void
|
||||
onSelectOption: (
|
||||
option: TOption,
|
||||
textNodeContainingQuery: TextNode | null,
|
||||
closeMenu: () => void,
|
||||
matchingString: string,
|
||||
) => void
|
||||
options: TOption[]
|
||||
menuRenderFn: MenuRenderFn<TOption>
|
||||
triggerFn: TriggerFn
|
||||
onOpen?: (resolution: MenuResolution) => void
|
||||
onClose?: () => void
|
||||
anchorClassName?: string
|
||||
commandPriority?: CommandListenerPriority
|
||||
parent?: HTMLElement
|
||||
}
|
||||
|
||||
export function LexicalTypeaheadMenuPlugin<TOption extends MenuOption>({
|
||||
options,
|
||||
onQueryChange,
|
||||
onSelectOption,
|
||||
onOpen,
|
||||
onClose,
|
||||
menuRenderFn,
|
||||
triggerFn,
|
||||
anchorClassName,
|
||||
commandPriority = COMMAND_PRIORITY_LOW,
|
||||
parent,
|
||||
}: TypeaheadMenuPluginProps<TOption>): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const [resolution, setResolution] = useState<MenuResolution | null>(null)
|
||||
const anchorElementRef = useMenuAnchorRef(
|
||||
resolution,
|
||||
setResolution,
|
||||
anchorClassName,
|
||||
parent,
|
||||
)
|
||||
|
||||
const closeTypeahead = useCallback(() => {
|
||||
setResolution(null)
|
||||
if (onClose != null && resolution !== null) {
|
||||
onClose()
|
||||
}
|
||||
}, [onClose, resolution])
|
||||
|
||||
const openTypeahead = useCallback(
|
||||
(res: MenuResolution) => {
|
||||
setResolution(res)
|
||||
if (onOpen != null && resolution === null) {
|
||||
onOpen(res)
|
||||
}
|
||||
},
|
||||
[onOpen, resolution],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const updateListener = () => {
|
||||
editor.getEditorState().read(() => {
|
||||
const editorWindow = editor._window ?? window
|
||||
const range = editorWindow.document.createRange()
|
||||
const selection = $getSelection()
|
||||
const text = getQueryTextForSearch(editor)
|
||||
|
||||
if (
|
||||
!$isRangeSelection(selection) ||
|
||||
!selection.isCollapsed() ||
|
||||
text === null ||
|
||||
range === null
|
||||
) {
|
||||
closeTypeahead()
|
||||
return
|
||||
}
|
||||
|
||||
const match = triggerFn(text, editor)
|
||||
onQueryChange(match ? match.matchingString : null)
|
||||
|
||||
if (
|
||||
match !== null &&
|
||||
!isSelectionOnEntityBoundary(editor, match.leadOffset)
|
||||
) {
|
||||
const isRangePositioned = tryToPositionRange(
|
||||
match.leadOffset,
|
||||
range,
|
||||
editorWindow,
|
||||
)
|
||||
if (isRangePositioned !== null) {
|
||||
startTransition(() =>
|
||||
openTypeahead({
|
||||
getRect: () => range.getBoundingClientRect(),
|
||||
match,
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
closeTypeahead()
|
||||
})
|
||||
}
|
||||
|
||||
const removeUpdateListener = editor.registerUpdateListener(updateListener)
|
||||
|
||||
return () => {
|
||||
removeUpdateListener()
|
||||
}
|
||||
}, [
|
||||
editor,
|
||||
triggerFn,
|
||||
onQueryChange,
|
||||
resolution,
|
||||
closeTypeahead,
|
||||
openTypeahead,
|
||||
])
|
||||
|
||||
return resolution === null || editor === null ? null : (
|
||||
<LexicalMenu
|
||||
close={closeTypeahead}
|
||||
resolution={resolution}
|
||||
editor={editor}
|
||||
anchorElementRef={anchorElementRef}
|
||||
options={options}
|
||||
menuRenderFn={menuRenderFn}
|
||||
shouldSplitNodeWithQuery={true}
|
||||
onSelectOption={onSelectOption}
|
||||
commandPriority={commandPriority}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
SerializedEditorState,
|
||||
SerializedElementNode,
|
||||
SerializedTextNode,
|
||||
} from 'lexical'
|
||||
|
||||
import { editorStateToPlainText } from './editor-state-to-plain-text'
|
||||
|
||||
describe('editorStateToPlainText', () => {
|
||||
it('should convert editor state to plain text', () => {
|
||||
const editorState: SerializedEditorState = {
|
||||
root: {
|
||||
children: [
|
||||
{
|
||||
children: [
|
||||
{
|
||||
detail: 0,
|
||||
format: 0,
|
||||
mode: 'normal',
|
||||
style: '',
|
||||
text: 'Hello, world!',
|
||||
type: 'text',
|
||||
version: 1,
|
||||
},
|
||||
],
|
||||
direction: 'ltr',
|
||||
format: '',
|
||||
indent: 0,
|
||||
type: 'paragraph',
|
||||
version: 1,
|
||||
textFormat: 0,
|
||||
textStyle: '',
|
||||
} as SerializedElementNode<SerializedTextNode>,
|
||||
],
|
||||
direction: 'ltr',
|
||||
format: '',
|
||||
indent: 0,
|
||||
type: 'root',
|
||||
version: 1,
|
||||
},
|
||||
}
|
||||
const plainText = editorStateToPlainText(editorState)
|
||||
expect(plainText).toBe('Hello, world!')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { SerializedEditorState, SerializedLexicalNode } from 'lexical'
|
||||
|
||||
export function editorStateToPlainText(
|
||||
editorState: SerializedEditorState,
|
||||
): string {
|
||||
return lexicalNodeToPlainText(editorState.root)
|
||||
}
|
||||
|
||||
function lexicalNodeToPlainText(node: SerializedLexicalNode): string {
|
||||
if ('children' in node) {
|
||||
// Process children recursively and join their results
|
||||
return (node.children as SerializedLexicalNode[])
|
||||
.map(lexicalNodeToPlainText)
|
||||
.join('')
|
||||
} else if (node.type === 'linebreak') {
|
||||
return '\n'
|
||||
} else if ('text' in node && typeof node.text === 'string') {
|
||||
return node.text
|
||||
}
|
||||
return ''
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
FileIcon,
|
||||
FolderClosedIcon,
|
||||
FoldersIcon,
|
||||
ImageIcon,
|
||||
LinkIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { Mentionable } from '../../../../types/mentionable'
|
||||
|
||||
export const getMentionableIcon = (mentionable: Mentionable) => {
|
||||
switch (mentionable.type) {
|
||||
case 'file':
|
||||
return FileIcon
|
||||
case 'folder':
|
||||
return FolderClosedIcon
|
||||
case 'vault':
|
||||
return FoldersIcon
|
||||
case 'current-file':
|
||||
return FileIcon
|
||||
case 'block':
|
||||
return FileIcon
|
||||
case 'url':
|
||||
return LinkIcon
|
||||
case 'image':
|
||||
return ImageIcon
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user