diff --git a/src/components/Editor/EditorManager.tsx b/src/components/Editor/EditorManager.tsx index 274004b2..6d0c7c5e 100644 --- a/src/components/Editor/EditorManager.tsx +++ b/src/components/Editor/EditorManager.tsx @@ -1,7 +1,5 @@ import React, { useCallback, useEffect, useState } from 'react' - import { Editor, EditorContent } from '@tiptap/react' - import InEditorBacklinkSuggestionsDisplay, { SuggestionsState } from './BacklinkSuggestionsDisplay' import EditorContextMenu from './EditorContextMenu' @@ -23,6 +21,8 @@ const EditorManager: React.FC = ({ const [menuVisible, setMenuVisible] = useState(false) const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }) const [editorFlex, setEditorFlex] = useState(true) + const [showPlaceholder, setShowPlaceholder] = useState(false) + const [placeholderPosition, setPlaceholderPosition] = useState({ top: 0, left: 0 }) const toggleSearch = useCallback(() => { setShowSearch((prevShowSearch) => !prevShowSearch) @@ -34,6 +34,7 @@ const EditorManager: React.FC = ({ setSearchTerm(value) editor?.commands.setSearchTerm(value) } + const goToSelection = () => { if (!editor) return const { results, resultIndex } = editor.storage.searchAndReplace @@ -45,6 +46,7 @@ const EditorManager: React.FC = ({ node.scrollIntoView?.(false) } } + const handleNextSearch = (event: React.KeyboardEvent) => { if (event.key === 'Enter') { event.preventDefault() @@ -100,6 +102,55 @@ const EditorManager: React.FC = ({ window.ipcRenderer.on('editor-flex-center-changed', handleEditorChange) }, []) + useEffect(() => { + if (!editor) return + + const handleUpdate = () => { + const { state } = editor + const { from, to } = state.selection + + const $from = state.doc.resolve(from) + const $to = state.doc.resolve(to) + const start = $from.before() + const end = $to.after() + + const currentLineText = state.doc.textBetween(start, end, '\n', ' ').trim() + + if (currentLineText === '') { + const { node } = editor.view.domAtPos(from) + const rect = (node as HTMLElement).getBoundingClientRect() + const editorRect = editor.view.dom.getBoundingClientRect() + setPlaceholderPosition({ top: rect.top - editorRect.top, left: rect.left - editorRect.left }) + setShowPlaceholder(true) + } else { + setShowPlaceholder(false) + } + } + + editor.on('update', handleUpdate) + editor.on('selectionUpdate', handleUpdate) + + // eslint-disable-next-line consistent-return + return () => { + editor.off('update', handleUpdate) + editor.off('selectionUpdate', handleUpdate) + } + }, [editor]) + + const handleInput = () => { + if (editor) { + const { state } = editor + const { from, to } = state.selection + + const $from = state.doc.resolve(from) + const $to = state.doc.resolve(to) + const start = $from.before() + const end = $to.after() + + const currentLineText = state.doc.textBetween(start, end, '\n', ' ').trim() + setShowPlaceholder(currentLineText === '') + } + } return (
= ({ }} onContextMenu={handleContextMenu} onClick={hideMenu} + onInput={handleInput} editor={editor} /> + {showPlaceholder && ( +
+ Press 'space' for AI writing assistant +
+ )}
{suggestionsState && ( = ({ const [prevPrompt, setPrevPrompt] = useState('') const [positionStyle, setPositionStyle] = useState({ top: 0, left: 0 }) const [markdownMaxHeight, setMarkdownMaxHeight] = useState('auto') + const [isSpaceTrigger, setIsSpaceTrigger] = useState(false) + const [spacePosition, setSpacePosition] = useState(null) + const [cursorPosition, setCursorPosition] = useState(null) const markdownContainerRef = useRef(null) const optionsContainerRef = useRef(null) + const textFieldRef = useRef(null) const hasValidMessages = currentChatHistory?.displayableChatHistory.some((msg) => msg.role === 'assistant') const lastAssistantMessage = currentChatHistory?.displayableChatHistory .filter((msg) => msg.role === 'assistant') @@ -42,9 +44,13 @@ const WritingAssistant: React.FC = ({ useOutsideClick(markdownContainerRef, () => { setCurrentChatHistory(undefined) + setIsSpaceTrigger(false) + setCustomPrompt('') }) useOutsideClick(optionsContainerRef, () => { setIsOptionsVisible(false) + setIsSpaceTrigger(false) + setCustomPrompt('') }) useEffect(() => { @@ -54,36 +60,40 @@ const WritingAssistant: React.FC = ({ }, [hasValidMessages]) useLayoutEffect(() => { - if (!isOptionsVisible) return - + if (!editor || (!isSpaceTrigger && !highlightData)) return const calculatePosition = () => { - if (!optionsContainerRef.current || !highlightData.position) { - return + if (!optionsContainerRef.current) return + const { from } = editor.state.selection + const coords = editor.view.coordsAtPos(from) + const viewportHeight = window.innerHeight + const optionsHeight = 200 + const spaceBelow = viewportHeight - coords.bottom + + let top = 0 + let left = 0 + if (spaceBelow >= optionsHeight) { + // Enough space below, position under the cursor + + left = coords.left - 50 + top = coords.bottom - 50 + } else if (spaceBelow < optionsHeight) { + // Not enough space below, position above the cursor + + left = coords.left - 100 + top = coords.top - optionsHeight } - const screenHeight = window.innerHeight - const elementHeight = optionsContainerRef.current.offsetHeight - const spaceBelow = screenHeight - highlightData.position.top - const isSpaceEnough = spaceBelow >= elementHeight - - if (isSpaceEnough) { - setPositionStyle({ - top: highlightData.position.top, - left: highlightData.position.left, - }) - } else { - setPositionStyle({ - top: highlightData.position.top - elementHeight, - left: highlightData.position.left, - }) - } + setPositionStyle({ + top, + left, + }) } calculatePosition() - }, [isOptionsVisible, highlightData.position]) + }, [isSpaceTrigger, highlightData, editor, isOptionsVisible]) useLayoutEffect(() => { - if (hasValidMessages && highlightData.position) { + if (hasValidMessages) { const calculateMaxHeight = () => { if (!markdownContainerRef.current) return @@ -102,7 +112,80 @@ const WritingAssistant: React.FC = ({ return () => window.removeEventListener('resize', calculateMaxHeight) } return () => {} - }, [hasValidMessages, highlightData.position, positionStyle.top]) + }, [hasValidMessages, positionStyle.top]) + + useEffect(() => { + if (editor) { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === ' ') { + const { from } = editor.state.selection + const $from = editor.state.doc.resolve(from) + const start = $from.start() + const lineText = editor.state.doc.textBetween(start, from, '\n', '\n') + + if (lineText.trim() === '' && from === start) { + event.preventDefault() + setCursorPosition(from) + + setIsSpaceTrigger(true) + setIsOptionsVisible(true) + setSpacePosition(from) + } + } + } + + editor.view.dom.addEventListener('keydown', handleKeyDown) + + return () => { + editor.view.dom.removeEventListener('keydown', handleKeyDown) + } + } + + return () => {} + }, [editor]) + + useEffect(() => { + if (editor && isSpaceTrigger && spacePosition !== null) { + const checkSpacePresence = () => { + const currentContent = editor.state.doc.textBetween(spacePosition, spacePosition + 1) + if (currentContent !== ' ') { + setIsOptionsVisible(false) + setIsSpaceTrigger(false) + setSpacePosition(null) + } + } + + editor.on('update', checkSpacePresence) + + return () => { + editor.off('update', checkSpacePresence) + } + } + return () => {} + }, [editor, isSpaceTrigger, spacePosition]) + + useEffect(() => { + const handleEscKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsOptionsVisible(false) + setIsSpaceTrigger(false) + setCustomPrompt('') + setCurrentChatHistory(undefined) + + // Return focus to the editor and set cursor position + if (editor && cursorPosition !== null) { + editor.commands.focus() + editor.commands.setTextSelection(cursorPosition) + } + } + } + + document.addEventListener('keydown', handleEscKey) + + return () => { + document.removeEventListener('keydown', handleEscKey) + } + }, [setCurrentChatHistory, editor, cursorPosition]) const copyToClipboard = () => { if (!editor || !currentChatHistory || currentChatHistory.displayableChatHistory.length === 0) { @@ -116,6 +199,7 @@ const WritingAssistant: React.FC = ({ if (copiedText) navigator.clipboard.writeText(copiedText) } + const insertAfterHighlightedText = () => { if (!editor || !currentChatHistory || currentChatHistory.displayableChatHistory.length === 0) { return @@ -135,6 +219,7 @@ const WritingAssistant: React.FC = ({ editor.chain().focus().setTextSelection(endOfSelection).insertContent(`\n${insertionText}`).run() setCurrentChatHistory(undefined) + setCustomPrompt('') } const replaceHighlightedText = () => { @@ -153,6 +238,7 @@ const WritingAssistant: React.FC = ({ } setCurrentChatHistory(undefined) + setCustomPrompt('') } const getLLMResponse = async (prompt: string, chatHistory: ChatHistory | undefined) => { @@ -166,7 +252,6 @@ const WritingAssistant: React.FC = ({ if (loadingResponse) return setLoadingResponse(true) - // make a new variable for chat history to not use the function parameter: let newChatHistory = chatHistory if (!newChatHistory || !newChatHistory.id) { const chatID = Date.now().toString() @@ -189,8 +274,10 @@ const WritingAssistant: React.FC = ({ } const handleOption = async (option: string, customPromptInput?: string) => { - const selectedText = highlightData.text - if (!selectedText.trim()) return + let selectedText = highlightData.text + if (!selectedText.trim() && isSpaceTrigger) { + selectedText = '' + } let prompt = '' @@ -214,11 +301,15 @@ Return only the edited text. Do not wrap your response in quotes. Do not offer a Write a markdown list (using dashes) of key takeaways from my notes. Write at least 3 items, but write more if the text requires it. Be very detailed and don't leave any information out. Do not wrap responses in quotes.` break default: - prompt = - 'The user has given the following instructions(in triple #) for processing the text selected(in triple quotes): ' + - `### ${customPromptInput} ###` + - '\n' + - ` """ ${selectedText} """` + if (selectedText.trim() === '') { + prompt = `The user has given the following instructions(in triple #) ### ${customPromptInput} ###` + } else { + prompt = + 'The user has given the following instructions(in triple #) for processing the text selected(in triple quotes): ' + + `### ${customPromptInput} ###` + + '\n' + + ` """ ${selectedText} """` + } break } setPrevPrompt(prompt) @@ -299,24 +390,25 @@ Write a markdown list (using dashes) of key takeaways from my notes. Write at le } return 'bg-blue-100 text-blue-800' } - if (!highlightData.position) return null - + if (!isSpaceTrigger && !highlightData.position) return null return (
- - {!hasValidMessages && isOptionsVisible && ( + {!isSpaceTrigger && highlightData.position && ( + + )} + {isOptionsVisible && !hasValidMessages && (