Skip to content

Commit

Permalink
implement AutoLinkMentionPlugin
Browse files Browse the repository at this point in the history
  • Loading branch information
glowingjade committed Oct 23, 2024
1 parent b60334d commit e50607b
Show file tree
Hide file tree
Showing 6 changed files with 223 additions and 3 deletions.
18 changes: 16 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"react-markdown": "^9.0.1",
"react-syntax-highlighter": "^15.5.0",
"remark-gfm": "^4.0.0",
"turndown": "^7.2.0",
"uuid": "^10.0.0"
}
}
}
2 changes: 2 additions & 0 deletions src/components/chat-view/chat-input/ChatUserInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { getMentionableKey } from '../../../utils/mentionable'

import MentionableBadge from './MentionableBadge'
import { ModelSelect } from './ModelSelect'
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'
Expand Down Expand Up @@ -258,6 +259,7 @@ const ChatUserInput = forwardRef<ChatUserInputRef, ChatUserInputProps>(
/>
<EditorRefPlugin editorRef={editorRef} />
<NoFormatPlugin />
<AutoLinkMentionPlugin />
</LexicalComposer>
<ModelSelect />
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import {
$createTextNode,
$getSelection,
$isRangeSelection,
COMMAND_PRIORITY_LOW,
PASTE_COMMAND,
TextNode,
} from 'lexical'
import { useEffect } from 'react'

import { 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()
}

export default function AutoLinkMentionPlugin() {
const [editor] = useLexicalComposerContext()

useEffect(() => {
editor.registerCommand(
PASTE_COMMAND,
(event) => {
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 = []
let lastIndex = 0

urlMatches.forEach((urlMatch) => {
// Add text node for unmatched part
if (urlMatch.index > lastIndex) {
nodes.push($createTextNode(text.slice(lastIndex, urlMatch.index)))
}

// Add mention node
nodes.push(
$createMentionNode(
urlMatch.text,
serializeMentionable({
type: 'url',
url: urlMatch.url,
}),
),
)

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
},
COMMAND_PRIORITY_LOW,
)

editor.registerNodeTransform(TextNode, $textNodeTransform)
}, [editor])

return null
}
10 changes: 10 additions & 0 deletions src/types/mentionable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,15 @@ export type MentionableBlockData = {
export type MentionableBlock = MentionableBlockData & {
type: 'block'
}
export type MentionableUrl = {
type: 'url'
url: string
}
export type Mentionable =
| MentionableFile
| MentionableCurrentFile
| MentionableBlock
| MentionableUrl

export type SerializedMentionableFile = {
type: 'file'
Expand All @@ -37,7 +42,12 @@ export type SerializedMentionableBlock = {
startLine: number
endLine: number
}
export type SerializedMentionableUrl = {
type: 'url'
url: string
}
export type SerializedMentionable =
| SerializedMentionableFile
| SerializedMentionableCurrentFile
| SerializedMentionableBlock
| SerializedMentionableUrl
15 changes: 15 additions & 0 deletions src/utils/mentionable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ export const serializeMentionable = (
startLine: mentionable.startLine,
endLine: mentionable.endLine,
}
case 'url':
return {
type: 'url',
url: mentionable.url,
}
}
}

Expand Down Expand Up @@ -69,6 +74,12 @@ export const deserializeMentionable = (
endLine: mentionable.endLine,
}
}
case 'url': {
return {
type: 'url',
url: mentionable.url,
}
}
}
} catch (e) {
console.error('Error deserializing mentionable', e)
Expand All @@ -84,6 +95,8 @@ export function getMentionableKey(mentionable: SerializedMentionable): string {
return `current-file:${mentionable.file ?? 'current'}`
case 'block':
return `block:${mentionable.file}:${mentionable.startLine}:${mentionable.endLine}:${mentionable.content}`
case 'url':
return `url:${mentionable.url}`
}
}

Expand All @@ -95,5 +108,7 @@ export function getMentionableName(mentionable: Mentionable): string {
return mentionable.file?.name ?? 'Current File'
case 'block':
return `${mentionable.file.name} (${mentionable.startLine}:${mentionable.endLine})`
case 'url':
return mentionable.url
}
}

0 comments on commit e50607b

Please sign in to comment.