Skip to content

Commit

Permalink
make mention copy and pastable (#30)
Browse files Browse the repository at this point in the history
* make mention copy and pastable

* add mentionable if new mention node is created
  • Loading branch information
glowingjade authored Oct 17, 2024
1 parent 2496718 commit cc58dd2
Show file tree
Hide file tree
Showing 12 changed files with 304 additions and 219 deletions.
43 changes: 8 additions & 35 deletions src/components/chat-view/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,24 +35,19 @@ import { parseRequestMessages } from '../../utils/prompt'

import ChatUserInput, { ChatUserInputRef } from './chat-input/ChatUserInput'
import { editorStateToPlainText } from './chat-input/utils/editor-state-to-plain-text'
import { generateMentionableId } from './chat-input/utils/get-mentionable-id'
import { ChatListDropdown } from './ChatListDropdown'
import ReactMarkdown from './ReactMarkdown'

// Add an empty line here
const getNewInputMessage = (app: App): ChatUserMessage => {
const mentionable: Omit<MentionableCurrentFile, 'id'> = {
type: 'current-file',
file: app.workspace.getActiveFile(),
}
return {
role: 'user',
content: null,
id: uuidv4(),
mentionables: [
{
id: generateMentionableId(mentionable),
...mentionable,
type: 'current-file',
file: app.workspace.getActiveFile(),
},
],
}
Expand Down Expand Up @@ -82,15 +77,11 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
const [inputMessage, setInputMessage] = useState<ChatUserMessage>(() => {
const newMessage = getNewInputMessage(app)
if (props.selectedBlock) {
const blockMentionable: Omit<MentionableBlock, 'id'> = {
type: 'block',
...props.selectedBlock,
}
newMessage.mentionables = [
...newMessage.mentionables,
{
id: generateMentionableId(blockMentionable),
...blockMentionable,
type: 'block',
...props.selectedBlock,
},
]
}
Expand Down Expand Up @@ -326,10 +317,7 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
setInputMessage((prevInputMessage) => ({
...prevInputMessage,
mentionables: [
{
id: generateMentionableId(mentionable),
...mentionable,
},
mentionable,
...prevInputMessage.mentionables.filter(
(mentionable) => mentionable.type !== 'current-file',
),
Expand All @@ -342,10 +330,7 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
? {
...message,
mentionables: [
{
id: generateMentionableId(mentionable),
...mentionable,
},
mentionable,
...message.mentionables.filter(
(mentionable) => mentionable.type !== 'current-file',
),
Expand Down Expand Up @@ -374,27 +359,15 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
if (focusedMessageId === inputMessage.id) {
setInputMessage((prevInputMessage) => ({
...prevInputMessage,
mentionables: [
...prevInputMessage.mentionables,
{
id: generateMentionableId(mentionable),
...mentionable,
},
],
mentionables: [...prevInputMessage.mentionables, mentionable],
}))
} else {
setChatMessages((prevChatHistory) =>
prevChatHistory.map((message) =>
message.id === focusedMessageId && message.role === 'user'
? {
...message,
mentionables: [
...message.mentionables,
{
id: generateMentionableId(mentionable),
...mentionable,
},
],
mentionables: [...message.mentionables, mentionable],
}
: message,
),
Expand Down
94 changes: 77 additions & 17 deletions src/components/chat-view/chat-input/ChatUserInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ 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 { PlainTextPlugin } from '@lexical/react/LexicalPlainTextPlugin'
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
import { $nodesOfType, LexicalEditor, SerializedEditorState } from 'lexical'
import {
forwardRef,
Expand All @@ -17,15 +17,21 @@ import {
useImperativeHandle,
useRef,
} from 'react'
import {
deserializeMentionable,
serializeMentionable,
} from 'src/utils/mentionable'

import { useApp } from '../../../contexts/app-context'
import { Mentionable } from '../../../types/mentionable'
import { Mentionable, SerializedMentionable } from '../../../types/mentionable'
import { fuzzySearch } from '../../../utils/fuzzy-search'
import { getMentionableKey } from '../../../utils/mentionable'

import MentionableBadge from './MentionableBadge'
import { ModelSelect } from './ModelSelect'
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,
Expand Down Expand Up @@ -103,7 +109,14 @@ const ChatUserInput = forwardRef<ChatUserInputRef, ChatUserInputProps>(
)

const handleMentionFile = (mentionable: Mentionable) => {
if (mentionables.some((m) => m.id === mentionable.id)) {
const mentionableKey = getMentionableKey(
serializeMentionable(mentionable),
)
if (
mentionables.some(
(m) => getMentionableKey(serializeMentionable(m)) === mentionableKey,
)
) {
return
}
setMentionables([...mentionables, mentionable])
Expand All @@ -112,33 +125,71 @@ const ChatUserInput = forwardRef<ChatUserInputRef, ChatUserInputProps>(
const handleMentionNodeMutation = (
mutations: NodeMutations<MentionNode>,
) => {
const destroyedMentionableIds: string[] = []
const destroyedMentionableKeys: string[] = []
const addedMentionables: SerializedMentionable[] = []
mutations.forEach((mutation) => {
if (mutation.mutation !== 'destroyed') return
const mentionable = mutation.node.getMentionable()
const mentionableKey = getMentionableKey(mentionable)

const id = mutation.node.getId()
if (mutation.mutation === 'destroyed') {
const nodeWithSameMentionable = editorRef.current?.read(() =>
$nodesOfType(MentionNode).find(
(node) =>
getMentionableKey(node.getMentionable()) === mentionableKey,
),
)

const nodeWithSameId = editorRef.current?.read(() =>
$nodesOfType(MentionNode).find((node) => node.getId() === id),
)
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
}

if (!nodeWithSameId) {
// remove mentionable only if it's not present in the editor state
destroyedMentionableIds.push(id)
addedMentionables.push(mentionable)
}
})

setMentionables(
mentionables.filter((m) => !destroyedMentionableIds.includes(m.id)),
mentionables
.filter(
(m) =>
!destroyedMentionableKeys.includes(
getMentionableKey(serializeMentionable(m)),
),
)
.concat(
addedMentionables
.map((m) => deserializeMentionable(m, app))
.filter((v) => !!v),
),
)
}

const handleMentionableDelete = (mentionable: Mentionable) => {
setMentionables(mentionables.filter((m) => m.id !== mentionable.id))
const mentionableKey = getMentionableKey(
serializeMentionable(mentionable),
)
setMentionables(
mentionables.filter(
(m) => getMentionableKey(serializeMentionable(m)) !== mentionableKey,
),
)

editorRef.current?.update(() => {
$nodesOfType(MentionNode).forEach((node) => {
if (node.getId() === mentionable.id) {
if (getMentionableKey(node.getMentionable()) === mentionableKey) {
node.remove()
}
})
Expand All @@ -151,7 +202,7 @@ const ChatUserInput = forwardRef<ChatUserInputRef, ChatUserInputProps>(
<div className="smtcmp-chat-user-input-files">
{mentionables.map((m) => (
<MentionableBadge
key={m.id}
key={getMentionableKey(serializeMentionable(m))}
mentionable={m}
onDelete={() => handleMentionableDelete(m)}
/>
Expand All @@ -160,7 +211,15 @@ const ChatUserInput = forwardRef<ChatUserInputRef, ChatUserInputProps>(
)}

<LexicalComposer initialConfig={initialConfig}>
<PlainTextPlugin
{/*
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"
Expand Down Expand Up @@ -198,6 +257,7 @@ const ChatUserInput = forwardRef<ChatUserInputRef, ChatUserInputProps>(
onMutation={handleMentionNodeMutation}
/>
<EditorRefPlugin editorRef={editorRef} />
<NoFormatPlugin />
</LexicalComposer>
<ModelSelect />
</div>
Expand Down
Loading

0 comments on commit cc58dd2

Please sign in to comment.