This commit is contained in:
duanfuxiang
2025-01-05 11:51:39 +08:00
commit 0c7ee142cb
215 changed files with 20611 additions and 0 deletions

View 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>
)
}

View 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

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
)
}

View 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>
}

View 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)

View 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;

View 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>
)
}

View 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)

View 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

View 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>
)
}

View 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>
)
}

View 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}
/>
)
}
}

View 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>
)
}

View 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>
)
}

View 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>
</>
)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}
/>
)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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
}
/>
)
}

View File

@@ -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}
/>
)
}

View File

@@ -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!')
})
})

View File

@@ -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 ''
}

View File

@@ -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
}
}