mirror of
https://github.com/EthanMarti/infio-copilot.git
synced 2026-05-08 16:10:09 +00:00
update chat view, handl tool & new md component
This commit is contained in:
@@ -26,8 +26,8 @@ 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'
|
||||
// import CreateTemplatePopoverPlugin from './plugins/template/CreateTemplatePopoverPlugin'
|
||||
// import TemplatePlugin from './plugins/template/TemplatePlugin'
|
||||
|
||||
export type LexicalContentEditableProps = {
|
||||
editorRef: RefObject<LexicalEditor>
|
||||
@@ -141,13 +141,13 @@ export default function LexicalContentEditable({
|
||||
<AutoLinkMentionPlugin />
|
||||
<ImagePastePlugin onCreateImageMentionables={onCreateImageMentionables} />
|
||||
<DragDropPaste onCreateImageMentionables={onCreateImageMentionables} />
|
||||
<TemplatePlugin />
|
||||
{plugins?.templatePopover && (
|
||||
{/* <TemplatePlugin /> */}
|
||||
{/* {plugins?.templatePopover && (
|
||||
<CreateTemplatePopoverPlugin
|
||||
anchorElement={plugins.templatePopover.anchorElement}
|
||||
contentEditableElement={contentEditableRef.current}
|
||||
/>
|
||||
)}
|
||||
)} */}
|
||||
</LexicalComposer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -265,9 +265,6 @@ const PromptInputWithActions = forwardRef<ChatUserInputRef, ChatUserInputProps>(
|
||||
handleSubmit({ useVaultSearch: true })
|
||||
},
|
||||
},
|
||||
templatePopover: {
|
||||
anchorElement: containerRef.current,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
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
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user