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