update template name -> command name

This commit is contained in:
duanfuxiang
2025-04-15 23:24:35 +08:00
parent 43599fca47
commit bde7df8b77
20 changed files with 622 additions and 225 deletions

View File

@@ -1,5 +1,6 @@
import * as path from 'path'
import { BaseSerializedNode } from '@lexical/clipboard/clipboard'
import { useMutation } from '@tanstack/react-query'
import { CircleStop, History, Plus, SquareSlash } from 'lucide-react'
import { App, Notice } from 'obsidian'
@@ -51,14 +52,13 @@ import { fetchUrlsContent, webSearch } from '../../utils/web-search'
import { ModeSelect } from './chat-input/ModeSelect'
import PromptInputWithActions, { ChatUserInputRef } from './chat-input/PromptInputWithActions'
import { editorStateToPlainText } from './chat-input/utils/editor-state-to-plain-text'
import { ChatHistory } from './ChatHistory'
import { ChatHistory } from './ChatHistoryView'
import CommandsView from './CommandsView'
import MarkdownReasoningBlock from './Markdown/MarkdownReasoningBlock'
import QueryProgress, { QueryProgressState } from './QueryProgress'
import ReactMarkdown from './ReactMarkdown'
import ShortcutInfo from './ShortcutInfo'
import SimilaritySearchResults from './SimilaritySearchResults'
// Add an empty line here
const getNewInputMessage = (app: App, defaultMention: string): ChatUserMessage => {
const mentionables: Mentionable[] = [];
@@ -161,7 +161,8 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
}
}
const [tab, setTab] = useState<'chat' | 'commands'>('commands')
const [tab, setTab] = useState<'chat' | 'commands'>('chat')
const [selectedSerializedNodes, setSelectedSerializedNodes] = useState<BaseSerializedNode[]>([])
useEffect(() => {
const scrollContainer = chatMessagesRef.current
@@ -184,6 +185,11 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
return () => scrollContainer.removeEventListener('scroll', handleScroll)
}, [chatMessages])
const handleCreateCommand = (serializedNodes: BaseSerializedNode[]) => {
setSelectedSerializedNodes(serializedNodes)
setTab('commands')
}
const handleScrollToBottom = () => {
if (chatMessagesRef.current) {
const scrollContainer = chatMessagesRef.current
@@ -890,6 +896,9 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
chatList={chatList}
currentConversationId={currentConversationId}
onSelect={async (conversationId) => {
if (tab !== 'chat') {
setTab('chat')
}
if (conversationId === currentConversationId) return
await handleLoadConversation(conversationId)
}}
@@ -969,6 +978,7 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
onFocus={() => {
setFocusedMessageId(message.id)
}}
onCreateCommand={handleCreateCommand}
mentionables={message.mentionables}
setMentionables={(mentionables) => {
setChatMessages((prevChatHistory) =>
@@ -1025,6 +1035,7 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
onFocus={() => {
setFocusedMessageId(inputMessage.id)
}}
onCreateCommand={handleCreateCommand}
mentionables={inputMessage.mentionables}
setMentionables={(mentionables) => {
setInputMessage((prevInputMessage) => ({
@@ -1038,7 +1049,9 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
</>
) : (
<div className="infio-chat-commands">
<CommandsView />
<CommandsView
selectedSerializedNodes={selectedSerializedNodes}
/>
</div>
)}
</div>

View File

@@ -1,162 +1,197 @@
import { Pencil, Save, Search, Trash2 } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { v4 as uuidv4 } from 'uuid'
import { $generateNodesFromSerializedNodes } from '@lexical/clipboard'
import { BaseSerializedNode } from '@lexical/clipboard/clipboard'
import { InitialEditorStateType } from '@lexical/react/LexicalComposer'
import { $getRoot, $insertNodes, LexicalEditor } from 'lexical'
import { Pencil, Search, Trash2 } from 'lucide-react'
import { Notice } from 'obsidian'
import { useCallback, useEffect, useRef, useState } from 'react'
// import { v4 as uuidv4 } from 'uuid'
export interface Command {
import { lexicalNodeToPlainText } from '../../components/chat-view/chat-input/utils/editor-state-to-plain-text'
import { useDatabase } from '../../contexts/DatabaseContext'
import { DBManager } from '../../database/database-manager'
import { TemplateContent } from '../../database/schema'
import LexicalContentEditable from './chat-input/LexicalContentEditable'
export interface QuickCommand {
id: string
title: string
content: string
name: string
content: TemplateContent
createdAt: Date | undefined
updatedAt: Date | undefined
}
const CommandsView = () => {
const [commands, setCommands] = useState<Command[]>([])
const [newCommand, setNewCommand] = useState<Command>({
id: uuidv4(),
title: '',
content: ''
})
const CommandsView = (
{
selectedSerializedNodes
}: {
selectedSerializedNodes?: BaseSerializedNode[]
}
) => {
const [commands, setCommands] = useState<QuickCommand[]>([])
const { getDatabaseManager } = useDatabase()
const getManager = useCallback(async (): Promise<DBManager> => {
return await getDatabaseManager()
}, [getDatabaseManager])
// init get all commands
const fetchCommands = useCallback(async () => {
const dbManager = await getManager()
dbManager.getCommandManager().getAllCommands((rows) => {
setCommands(rows.map((row) => ({
id: row.id,
name: row.name,
content: row.content,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
})))
})
}, [getManager])
useEffect(() => {
void fetchCommands()
}, [fetchCommands])
// new command name
const [newCommandName, setNewCommandName] = useState('')
// search term
const [searchTerm, setSearchTerm] = useState('')
// editing command id
const [editingCommandId, setEditingCommandId] = useState<string | null>(null)
const titleInputRefs = useRef<Map<string, HTMLInputElement>>(new Map())
const contentInputRefs = useRef<Map<string, HTMLTextAreaElement>>(new Map())
const nameInputRefs = useRef<Map<string, HTMLInputElement>>(new Map())
const contentEditorRefs = useRef<Map<string, LexicalEditor>>(new Map())
const contentEditableRefs = useRef<Map<string, HTMLDivElement>>(new Map())
// 从本地存储加载commands
useEffect(() => {
const savedCommands = localStorage.getItem('commands')
if (savedCommands) {
try {
const parsedData = JSON.parse(savedCommands)
// 验证解析的数据是否为符合Prompt接口的数组
if (Array.isArray(parsedData) && parsedData.every(isCommand)) {
setCommands(parsedData)
}
} catch (error) {
console.error('无法解析保存的命令', error)
}
}
}, [])
// 类型守卫函数用于验证对象是否符合Command接口
function isCommand(item: unknown): item is Command {
if (!item || typeof item !== 'object') {
return false;
}
// 使用in操作符检查属性存在
if (!('id' in item) || !('title' in item) || !('content' in item)) {
return false;
}
// 使用JavaScript的hasOwnProperty和typeof来检查属性类型
return (
Object.prototype.hasOwnProperty.call(item, 'id') &&
Object.prototype.hasOwnProperty.call(item, 'title') &&
Object.prototype.hasOwnProperty.call(item, 'content') &&
typeof Reflect.get(item, 'id') === 'string' &&
typeof Reflect.get(item, 'title') === 'string' &&
typeof Reflect.get(item, 'content') === 'string'
);
}
// 保存commands到本地存储
useEffect(() => {
localStorage.setItem('commands', JSON.stringify(commands))
}, [commands])
// 处理新command的标题变化
const handleNewCommandTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setNewCommand({ ...newCommand, title: e.target.value })
}
// 处理新command的内容变化
const handleNewCommandContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setNewCommand({ ...newCommand, content: e.target.value })
}
// 添加新command
const handleAddCommand = () => {
if (newCommand.title.trim() === '' || newCommand.content.trim() === '') {
return
}
setCommands([...commands, newCommand])
setNewCommand({
id: uuidv4(),
title: '',
content: ''
// new command content's editor state
const initialEditorState: InitialEditorStateType = (
editor: LexicalEditor,
) => {
if (!selectedSerializedNodes) return
editor.update(() => {
const parsedNodes = $generateNodesFromSerializedNodes(
selectedSerializedNodes,
)
$insertNodes(parsedNodes)
})
}
// new command content's editor
const editorRef = useRef<LexicalEditor | null>(null)
// new command content's editable
const contentEditableRef = useRef<HTMLDivElement | null>(null)
// 删除command
const handleDeleteCommand = (id: string) => {
setCommands(commands.filter(command => command.id !== id))
if (editingCommandId === id) {
setEditingCommandId(null)
// Create new command
const handleAddCommand = async () => {
const serializedEditorState = editorRef.current.toJSON()
const nodes = serializedEditorState.editorState.root.children
if (nodes.length === 0) {
new Notice('Please enter a content for your template')
return
}
if (newCommandName.trim().length === 0) {
new Notice('Please enter a name for your template')
return
}
const dbManager = await getManager()
dbManager.getCommandManager().createCommand({
name: newCommandName,
content: { nodes },
})
// clear editor content
editorRef.current.update(() => {
const root = $getRoot()
root.clear()
})
setNewCommandName('')
}
// 编辑command
const handleEditCommand = (command: Command) => {
// delete command
const handleDeleteCommand = async (id: string) => {
const dbManager = await getManager()
await dbManager.getCommandManager().deleteCommand(id)
}
// edit command
const handleEditCommand = (command: QuickCommand) => {
setEditingCommandId(command.id)
}
// 保存编辑后的command
const handleSaveEdit = (id: string) => {
const titleInput = titleInputRefs.current.get(id)
const contentInput = contentInputRefs.current.get(id)
if (titleInput && contentInput) {
setCommands(
commands.map(command =>
command.id === id
? { ...command, title: titleInput.value, content: contentInput.value }
: command
)
)
setEditingCommandId(null)
// save edited command
const handleSaveEdit = async (id: string) => {
const nameInput = nameInputRefs.current.get(id)
const currContentEditorRef = contentEditorRefs.current.get(id)
if (!currContentEditorRef) {
new Notice('Please enter a content for your template')
return
}
const serializedEditorState = currContentEditorRef.toJSON()
const nodes = serializedEditorState.editorState.root.children
if (nodes.length === 0) {
new Notice('Please enter a content for your template')
return
}
const dbManager = await getManager()
await dbManager.getCommandManager().updateCommand(id, {
name: nameInput.value,
content: { nodes },
})
setEditingCommandId(null)
}
// 处理搜索
// handle search
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value)
}
// 过滤commands列表
// filter commands list
const filteredCommands = commands.filter(
command =>
command.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
command.content.toLowerCase().includes(searchTerm.toLowerCase())
command.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
command.content.nodes.map(lexicalNodeToPlainText).join('').toLowerCase().includes(searchTerm.toLowerCase())
)
const getCommandEditorState = (commandContent: TemplateContent): InitialEditorStateType => {
return (editor: LexicalEditor) => {
editor.update(() => {
const parsedNodes = $generateNodesFromSerializedNodes(
commandContent.nodes,
)
$insertNodes(parsedNodes)
})
}
}
return (
<div className="infio-commands-container">
{/* header */}
<div className="infio-commands-header">
<div className="infio-commands-new">
<h2 className="infio-commands-header-title">Create Quick Command</h2>
<h2 className="infio-commands-header-name">Create Quick Command</h2>
<div className="infio-commands-label">Name</div>
<input
type="text"
placeholder="Input Command Name"
value={newCommand.title}
onChange={handleNewCommandTitleChange}
value={newCommandName}
onChange={(e) => setNewCommandName(e.target.value)}
className="infio-commands-input"
/>
<div className="infio-commands-label">Content</div>
<textarea
placeholder="Input Command Content"
value={newCommand.content}
onChange={handleNewCommandContentChange}
className="infio-commands-textarea"
/>
{/* <div className="infio-commands-hint">English identifier (lowercase letters + numbers + hyphens)</div> */}
<div className="infio-commands-textarea">
<LexicalContentEditable
initialEditorState={initialEditorState}
editorRef={editorRef}
contentEditableRef={contentEditableRef}
/>
</div>
<button
onClick={handleAddCommand}
className="infio-commands-add-btn"
disabled={!newCommand.title.trim() || !newCommand.content.trim()}
disabled={!newCommandName.trim()}
>
<span>Create Command</span>
</button>
@@ -183,49 +218,53 @@ const CommandsView = () => {
</div>
) : (
filteredCommands.map(command => (
<div key={command.id} className="infio-commands-item">
<div key={command.name} className="infio-commands-item">
{editingCommandId === command.id ? (
// edit mode
<div className="infio-commands-edit-mode">
<input
type="text"
defaultValue={command.title}
className="infio-commands-edit-title"
defaultValue={command.name}
className="infio-commands-edit-name"
ref={(el) => {
if (el) titleInputRefs.current.set(command.id, el)
}}
/>
<textarea
defaultValue={command.content}
className="infio-commands-textarea"
ref={(el) => {
if (el) contentInputRefs.current.set(command.id, el)
if (el) nameInputRefs.current.set(command.id, el)
}}
/>
<div className="infio-commands-textarea">
<LexicalContentEditable
initialEditorState={getCommandEditorState(command.content)}
editorRef={(editor: LexicalEditor) => {
if (editor) contentEditorRefs.current.set(command.id, editor)
}}
contentEditableRef={(el: HTMLDivElement) => {
if (el) contentEditableRefs.current.set(command.id, el)
}}
/>
</div>
<div className="infio-commands-actions">
<button
onClick={() => handleSaveEdit(command.id)}
className="infio-commands-btn"
className="infio-commands-add-btn"
>
<Save size={16} />
<span>Update Command</span>
</button>
</div>
</div>
) : (
// view mode
<div className="infio-commands-view-mode">
<div className="infio-commands-title">{command.title}</div>
<div className="infio-commands-content">{command.content}</div>
<div className="infio-commands-name">{command.name}</div>
<div className="infio-commands-content">{command.content.nodes.map(lexicalNodeToPlainText).join('')}</div>
<div className="infio-commands-actions">
<button
onClick={() => handleEditCommand(command)}
className="infio-commands-btn"
className="infio-commands-btn"
>
<Pencil size={16} />
</button>
<button
onClick={() => handleDeleteCommand(command.id)}
className="infio-commands-btn"
className="infio-commands-btn"
>
<Trash2 size={16} />
</button>

View File

@@ -1,3 +1,4 @@
import { BaseSerializedNode } from '@lexical/clipboard/clipboard'
import {
InitialConfigType,
InitialEditorStateType,
@@ -26,8 +27,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 CreateCommandPopoverPlugin from './plugins/command/CreateCommandPopoverPlugin'
import CommandPlugin from './plugins/command/CommandPlugin'
export type LexicalContentEditableProps = {
editorRef: RefObject<LexicalEditor>
@@ -43,8 +44,9 @@ export type LexicalContentEditableProps = {
onEnter?: {
onVaultChat: () => void
}
templatePopover?: {
commandPopover?: {
anchorElement: HTMLElement | null
onCreateCommand: (nodes: BaseSerializedNode[]) => void
}
}
}
@@ -141,13 +143,14 @@ export default function LexicalContentEditable({
<AutoLinkMentionPlugin />
<ImagePastePlugin onCreateImageMentionables={onCreateImageMentionables} />
<DragDropPaste onCreateImageMentionables={onCreateImageMentionables} />
{/* <TemplatePlugin /> */}
{/* {plugins?.templatePopover && (
<CreateTemplatePopoverPlugin
anchorElement={plugins.templatePopover.anchorElement}
<CommandPlugin />
{plugins?.commandPopover && (
<CreateCommandPopoverPlugin
anchorElement={plugins.commandPopover.anchorElement}
contentEditableElement={contentEditableRef.current}
onCreateCommand={plugins.commandPopover.onCreateCommand}
/>
)} */}
)}
</LexicalComposer>
)
}

View File

@@ -1,3 +1,4 @@
import { BaseSerializedNode } from '@lexical/clipboard/clipboard'
import { useQuery } from '@tanstack/react-query'
import { $nodesOfType, LexicalEditor, SerializedEditorState } from 'lexical'
import {
@@ -30,7 +31,7 @@ import { ImageUploadButton } from './ImageUploadButton'
import LexicalContentEditable from './LexicalContentEditable'
import MentionableBadge from './MentionableBadge'
import { ModelSelect } from './ModelSelect'
import { ModeSelect } from './ModeSelect'
// import { ModeSelect } from './ModeSelect'
import { MentionNode } from './plugins/mention/MentionNode'
import { NodeMutations } from './plugins/on-mutation/OnMutationPlugin'
import { SubmitButton } from './SubmitButton'
@@ -44,6 +45,7 @@ export type ChatUserInputProps = {
onChange?: (content: SerializedEditorState) => void
onSubmit: (content: SerializedEditorState, useVaultSearch?: boolean) => void
onFocus: () => void
onCreateCommand: (nodes: BaseSerializedNode[]) => void
mentionables: Mentionable[]
setMentionables: (mentionables: Mentionable[]) => void
autoFocus?: boolean
@@ -57,6 +59,7 @@ const PromptInputWithActions = forwardRef<ChatUserInputRef, ChatUserInputProps>(
onChange,
onSubmit,
onFocus,
onCreateCommand,
mentionables,
setMentionables,
autoFocus = false,
@@ -266,6 +269,10 @@ const PromptInputWithActions = forwardRef<ChatUserInputRef, ChatUserInputProps>(
handleSubmit({ useVaultSearch: true })
},
},
commandPopover: {
anchorElement: containerRef.current,
onCreateCommand: onCreateCommand,
},
}}
/>

View File

@@ -0,0 +1,189 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import clsx from 'clsx'
import {
$parseSerializedNode,
COMMAND_PRIORITY_NORMAL, SerializedLexicalNode, TextNode
} from 'lexical'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { lexicalNodeToPlainText } from '../../../../../components/chat-view/chat-input/utils/editor-state-to-plain-text'
import { useDatabase } from '../../../../../contexts/DatabaseContext'
import { DBManager } from '../../../../../database/database-manager'
import { MenuOption } from '../shared/LexicalMenu'
import {
LexicalTypeaheadMenuPlugin,
useBasicTypeaheadTriggerMatch,
} from '../typeahead-menu/LexicalTypeaheadMenuPlugin'
export type Command = {
id: string
name: string
content: { nodes: SerializedLexicalNode[] }
createdAt: Date
updatedAt: Date
}
class CommandTypeaheadOption extends MenuOption {
name: string
command: Command
constructor(name: string, command: Command) {
super(name)
this.name = name
this.command = command
}
}
function CommandMenuItem({
index,
isSelected,
onClick,
onMouseEnter,
option,
}: {
index: number
isSelected: boolean
onClick: () => void
onMouseEnter: () => void
option: CommandTypeaheadOption
}) {
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="smtcmp-template-menu-item">
<div className="text">{option.name}</div>
</div>
</li>
)
}
export default function CommandPlugin() {
const [editor] = useLexicalComposerContext()
const [commands, setCommands] = useState<Command[]>([])
const { getDatabaseManager } = useDatabase()
const getManager = useCallback(async (): Promise<DBManager> => {
return await getDatabaseManager()
}, [getDatabaseManager])
const fetchCommands = useCallback(async () => {
const dbManager = await getManager()
dbManager.getCommandManager().getAllCommands((rows) => {
setCommands(rows.map((row) => ({
id: row.id,
name: row.name,
content: row.content,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
})))
})
}, [getManager])
useEffect(() => {
void fetchCommands()
}, [fetchCommands])
const [queryString, setQueryString] = useState<string | null>(null)
const [searchResults, setSearchResults] = useState<Command[]>([])
useEffect(() => {
if (queryString == null) return
const filteredCommands = commands.filter(
command =>
command.name.toLowerCase().includes(queryString.toLowerCase()) ||
command.content.nodes.map(lexicalNodeToPlainText).join('').toLowerCase().includes(queryString.toLowerCase())
)
setSearchResults(filteredCommands)
}, [queryString, commands])
const options = useMemo(
() =>
searchResults.map(
(result) => new CommandTypeaheadOption(result.name, result),
),
[searchResults],
)
const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', {
minLength: 0,
})
const onSelectOption = useCallback(
(
selectedOption: CommandTypeaheadOption,
nodeToRemove: TextNode | null,
closeMenu: () => void,
) => {
editor.update(() => {
const parsedNodes = selectedOption.command.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],
)
return (
<LexicalTypeaheadMenuPlugin<CommandTypeaheadOption>
onQueryChange={setQueryString}
onSelectOption={onSelectOption}
triggerFn={checkForTriggerMatch}
options={options}
commandPriority={COMMAND_PRIORITY_NORMAL}
menuRenderFn={(
anchorElementRef,
{ selectedIndex, selectOptionAndCleanUp, setHighlightedIndex },
) =>
anchorElementRef.current && searchResults.length
? createPortal(
<div
className="smtcmp-popover"
style={{
position: 'fixed',
}}
>
<ul>
{options.map((option, i: number) => (
<CommandMenuItem
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,127 @@
import { $generateJSONFromSelectedNodes } from '@lexical/clipboard'
import { BaseSerializedNode } from '@lexical/clipboard/clipboard'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import {
$getSelection,
COMMAND_PRIORITY_LOW,
SELECTION_CHANGE_COMMAND,
} from 'lexical'
import { CSSProperties, useCallback, useEffect, useRef, useState } from 'react'
export default function CreateCommandPopoverPlugin({
anchorElement,
contentEditableElement,
onCreateCommand,
}: {
anchorElement: HTMLElement | null
contentEditableElement: HTMLElement | null
onCreateCommand: (nodes: BaseSerializedNode[]) => void
}): JSX.Element | null {
const [editor] = useLexicalComposerContext()
const [popoverStyle, setPopoverStyle] = useState<CSSProperties | null>(null)
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
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 (
<button
ref={popoverRef}
style={{
position: 'absolute',
visibility: isPopoverOpen ? 'visible' : 'hidden',
...popoverStyle,
}}
onClick={() => {
onCreateCommand(getSelectedSerializedNodes() ?? [])
setIsPopoverOpen(false)
}}
>
create command
</button>
)
}

View File

@@ -6,7 +6,7 @@ export function editorStateToPlainText(
return lexicalNodeToPlainText(editorState.root)
}
function lexicalNodeToPlainText(node: SerializedLexicalNode): string {
export function lexicalNodeToPlainText(node: SerializedLexicalNode): string {
if ('children' in node) {
// Process children recursively and join their results
return (node.children as SerializedLexicalNode[])