diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index fa3fb5d8..a8004f72 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -31,9 +31,12 @@ import { PromptInputBody, PromptInputFooter, type PromptInputMessage, + PromptInputProvider, PromptInputSubmit, PromptInputTextarea, PromptInputTools, + usePromptInputController, + type FileMention, } from '@/components/ai-elements/prompt-input'; import { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning'; import { Shimmer } from '@/components/ai-elements/shimmer'; @@ -252,6 +255,99 @@ const collectDirPaths = (nodes: TreeNode[]): string[] => const collectFilePaths = (nodes: TreeNode[]): string[] => nodes.flatMap(n => n.kind === 'file' ? [n.path] : (n.children ? collectFilePaths(n.children) : [])) +// Inner component that uses the controller to access mentions +interface ChatInputInnerProps { + onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void + isProcessing: boolean + contextUsage: LanguageModelUsage + maxTokens: number + usedTokens: number +} + +function ChatInputInner({ + onSubmit, + isProcessing, + contextUsage, + maxTokens, + usedTokens, +}: ChatInputInnerProps) { + const controller = usePromptInputController() + const message = controller.textInput.value + const canSubmit = Boolean(message.trim()) && !isProcessing + const submitStatus: ChatStatus = isProcessing ? 'streaming' : 'ready' + + const handleSubmit = useCallback((msg: PromptInputMessage) => { + onSubmit(msg, controller.mentions.mentions) + controller.mentions.clearMentions() + }, [onSubmit, controller.mentions]) + + return ( + + + + + + + + + + + + + + + + + + + + + + + ) +} + +// Wrapper component with PromptInputProvider +interface ChatInputWithMentionsProps { + knowledgeFiles: string[] + onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void + isProcessing: boolean + contextUsage: LanguageModelUsage + maxTokens: number + usedTokens: number +} + +function ChatInputWithMentions({ + knowledgeFiles, + onSubmit, + isProcessing, + contextUsage, + maxTokens, + usedTokens, +}: ChatInputWithMentionsProps) { + return ( + + + + ) +} + function App() { // File browser state (for Knowledge section) const [selectedPath, setSelectedPath] = useState(null) @@ -577,7 +673,7 @@ function App() { } } - const handlePromptSubmit = async (message: PromptInputMessage) => { + const handlePromptSubmit = async (message: PromptInputMessage, mentions?: FileMention[]) => { if (isProcessing) return const { text } = message; @@ -604,9 +700,32 @@ function App() { setRunId(currentRunId) } + // Read mentioned file contents and format message with XML context + let formattedMessage = userMessage + if (mentions && mentions.length > 0) { + const attachedFiles = await Promise.all( + mentions.map(async (m) => { + try { + const result = await window.ipc.invoke('workspace:readFile', { path: m.path }) + return { path: m.path, content: result.data as string } + } catch (err) { + console.error('Failed to read mentioned file:', m.path, err) + return { path: m.path, content: `[Error reading file: ${m.path}]` } + } + }) + ) + + if (attachedFiles.length > 0) { + const filesXml = attachedFiles + .map(f => `\n${f.content}\n`) + .join('\n') + formattedMessage = `\n${filesXml}\n\n\n${userMessage}` + } + } + await window.ipc.invoke('runs:createMessage', { runId: currentRunId, - message: userMessage, + message: formattedMessage, }) } catch (error) { console.error('Failed to send message:', error) @@ -979,8 +1098,6 @@ function App() { const conversationContentClassName = hasConversation ? "mx-auto w-full max-w-4xl pb-28" : "mx-auto w-full max-w-4xl min-h-full items-center justify-center pb-0" - const submitStatus: ChatStatus = isProcessing ? 'streaming' : 'ready' - const canSubmit = Boolean(message.trim()) && !isProcessing const headerTitle = selectedPath ? selectedPath : (isGraphOpen ? 'Graph View' : 'Chat') return ( @@ -1117,40 +1234,14 @@ function App() { - - - setMessage(e.target.value)} - placeholder="Type your message..." - disabled={isProcessing} - /> - - - - - - - - - - - - - - - - - - - + @@ -1173,6 +1264,8 @@ function App() { contextUsage={contextUsage} maxTokens={maxTokens} usedTokens={usedTokens} + knowledgeFiles={knowledgeFiles} + selectedPath={selectedPath} /> )} diff --git a/apps/x/apps/renderer/src/components/ai-elements/prompt-input.tsx b/apps/x/apps/renderer/src/components/ai-elements/prompt-input.tsx index a529af66..68544007 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/prompt-input.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/prompt-input.tsx @@ -47,6 +47,9 @@ import { XIcon, } from "lucide-react"; import { nanoid } from "nanoid"; +import { useMentionDetection } from "@/hooks/use-mention-detection"; +import { MentionPopover } from "@/components/mention-popover"; +import { toKnowledgePath } from "@/lib/wiki-links"; import { type ChangeEvent, type ChangeEventHandler, @@ -83,6 +86,19 @@ export type AttachmentsContext = { fileInputRef: RefObject; }; +export type FileMention = { + id: string; + path: string; // "knowledge/notes.md" + displayName: string; // "notes" +}; + +export type MentionsContext = { + mentions: FileMention[]; + addMention: (path: string, displayName: string) => void; + removeMention: (id: string) => void; + clearMentions: () => void; +}; + export type TextInputContext = { value: string; setInput: (v: string) => void; @@ -92,6 +108,7 @@ export type TextInputContext = { export type PromptInputControllerProps = { textInput: TextInputContext; attachments: AttachmentsContext; + mentions: MentionsContext; /** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */ __registerFileInput: ( ref: RefObject, @@ -105,6 +122,7 @@ const PromptInputController = createContext( const ProviderAttachmentsContext = createContext( null ); +const ProviderMentionsContext = createContext(null); export const usePromptInputController = () => { const ctx = useContext(PromptInputController); @@ -133,8 +151,31 @@ export const useProviderAttachments = () => { const useOptionalProviderAttachments = () => useContext(ProviderAttachmentsContext); +export const useProviderMentions = () => { + const ctx = useContext(ProviderMentionsContext); + if (!ctx) { + throw new Error( + "Wrap your component inside to use useProviderMentions()." + ); + } + return ctx; +}; + +const useOptionalProviderMentions = () => useContext(ProviderMentionsContext); + +export type KnowledgeFilesContext = { + files: string[]; +}; + +const ProviderKnowledgeFilesContext = createContext(null); + +export const useProviderKnowledgeFiles = () => { + return useContext(ProviderKnowledgeFilesContext); +}; + export type PromptInputProviderProps = PropsWithChildren<{ initialInput?: string; + knowledgeFiles?: string[]; }>; /** @@ -143,6 +184,7 @@ export type PromptInputProviderProps = PropsWithChildren<{ */ export function PromptInputProvider({ initialInput: initialTextInput = "", + knowledgeFiles = [], children, }: PromptInputProviderProps) { // ----- textInput state @@ -227,6 +269,37 @@ export function PromptInputProvider({ [attachmentFiles, add, remove, clear, openFileDialog] ); + // ----- mentions state (for @ file mentions) + const [mentionsList, setMentionsList] = useState([]); + + const addMention = useCallback((path: string, displayName: string) => { + setMentionsList((prev) => { + // Avoid duplicates + if (prev.some((m) => m.path === path)) { + return prev; + } + return [...prev, { id: nanoid(), path, displayName }]; + }); + }, []); + + const removeMention = useCallback((id: string) => { + setMentionsList((prev) => prev.filter((m) => m.id !== id)); + }, []); + + const clearMentions = useCallback(() => { + setMentionsList([]); + }, []); + + const mentions = useMemo( + () => ({ + mentions: mentionsList, + addMention, + removeMention, + clearMentions, + }), + [mentionsList, addMention, removeMention, clearMentions] + ); + const __registerFileInput = useCallback( (ref: RefObject, open: () => void) => { fileInputRef.current = ref.current; @@ -243,15 +316,25 @@ export function PromptInputProvider({ clear: clearInput, }, attachments, + mentions, __registerFileInput, }), - [textInput, clearInput, attachments, __registerFileInput] + [textInput, clearInput, attachments, mentions, __registerFileInput] + ); + + const knowledgeFilesContext = useMemo( + () => ({ files: knowledgeFiles }), + [knowledgeFiles] ); return ( - {children} + + + {children} + + ); @@ -824,10 +907,66 @@ export const PromptInputTextarea = ({ }: PromptInputTextareaProps) => { const controller = useOptionalPromptInputController(); const attachments = usePromptInputAttachments(); + const mentionsCtx = useOptionalProviderMentions(); + const knowledgeFilesCtx = useProviderKnowledgeFiles(); const [isComposing, setIsComposing] = useState(false); + const textareaRef = useRef(null); + const containerRef = useRef(null); + + const currentValue = controller?.textInput.value ?? ""; + const knowledgeFiles = knowledgeFilesCtx?.files ?? []; + + const { activeMention, cursorCoords } = useMentionDetection( + textareaRef, + currentValue, + knowledgeFiles.length > 0 + ); + + const handleMentionSelect = useCallback( + (path: string, displayName: string) => { + if (!controller || !activeMention) return; + + // Calculate the text before and after the @query + const currentText = controller.textInput.value; + const beforeAt = currentText.substring(0, activeMention.triggerIndex); + const afterQuery = currentText.substring( + activeMention.triggerIndex + 1 + activeMention.query.length + ); + + // Replace @query with @displayName followed by a space + const newText = `${beforeAt}@${displayName} ${afterQuery}`; + controller.textInput.setInput(newText); + + // Convert to knowledge path and add mention + const fullPath = toKnowledgePath(path); + if (fullPath && mentionsCtx) { + mentionsCtx.addMention(fullPath, displayName); + } + + // Focus back on textarea + textareaRef.current?.focus(); + }, + [controller, activeMention, mentionsCtx] + ); + + const handleMentionClose = useCallback(() => { + // The popover handles its own closing + }, []); + const handleKeyDown: KeyboardEventHandler = (e) => { + // If mention popover is open, let it handle navigation keys + if (activeMention && ["ArrowDown", "ArrowUp", "Tab"].includes(e.key)) { + // Don't prevent default here - the popover handles this via document listener + return; + } + if (e.key === "Enter") { + // If mention popover is open, Enter should select the item + if (activeMention) { + return; + } + if (isComposing || e.nativeEvent.isComposing) { return; } @@ -860,6 +999,12 @@ export const PromptInputTextarea = ({ attachments.remove(lastAttachment.id); } } + + // Close mention popover on Escape + if (e.key === "Escape" && activeMention) { + // Let the popover handle this + return; + } }; const handlePaste: ClipboardEventHandler = (event) => { @@ -899,17 +1044,31 @@ export const PromptInputTextarea = ({ }; return ( - setIsComposing(false)} - onCompositionStart={() => setIsComposing(true)} - onKeyDown={handleKeyDown} - onPaste={handlePaste} - placeholder={placeholder} - {...props} - {...controlledProps} - /> + + setIsComposing(false)} + onCompositionStart={() => setIsComposing(true)} + onKeyDown={handleKeyDown} + onPaste={handlePaste} + placeholder={placeholder} + {...props} + {...controlledProps} + /> + {knowledgeFiles.length > 0 && ( + + )} + ); }; diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 910514b0..0a2470e0 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -22,7 +22,10 @@ import { import { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning' import { Shimmer } from '@/components/ai-elements/shimmer' import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool' -import { type PromptInputMessage } from '@/components/ai-elements/prompt-input' +import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input' +import { useMentionDetection } from '@/hooks/use-mention-detection' +import { MentionPopover } from '@/components/mention-popover' +import { toKnowledgePath, wikiLabel } from '@/lib/wiki-links' interface ChatMessage { id: string @@ -107,10 +110,12 @@ interface ChatSidebarProps { isProcessing: boolean message: string onMessageChange: (message: string) => void - onSubmit: (message: PromptInputMessage) => void + onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void contextUsage: LanguageModelUsage maxTokens: number usedTokens: number + knowledgeFiles?: string[] + selectedPath?: string | null } export function ChatSidebar({ @@ -124,12 +129,51 @@ export function ChatSidebar({ message, onMessageChange, onSubmit, + knowledgeFiles = [], + selectedPath, }: ChatSidebarProps) { const [width, setWidth] = useState(defaultWidth) const [isResizing, setIsResizing] = useState(false) const startXRef = useRef(0) const startWidthRef = useRef(0) - const inputRef = useRef(null) + const textareaRef = useRef(null) + const containerRef = useRef(null) + const [mentions, setMentions] = useState([]) + + const { activeMention, cursorCoords } = useMentionDetection( + textareaRef, + message, + knowledgeFiles.length > 0 + ) + + const handleMentionSelect = useCallback( + (path: string, displayName: string) => { + if (!activeMention) return + + const beforeAt = message.substring(0, activeMention.triggerIndex) + const afterQuery = message.substring( + activeMention.triggerIndex + 1 + activeMention.query.length + ) + + const newText = `${beforeAt}@${displayName} ${afterQuery}` + onMessageChange(newText) + + const fullPath = toKnowledgePath(path) + if (fullPath) { + setMentions(prev => { + if (prev.some(m => m.path === fullPath)) return prev + return [...prev, { id: `mention-${Date.now()}`, path: fullPath, displayName }] + }) + } + + textareaRef.current?.focus() + }, + [activeMention, message, onMessageChange] + ) + + const handleMentionClose = useCallback(() => { + // The popover handles its own closing + }, []) const handleMouseDown = useCallback((e: React.MouseEvent) => { e.preventDefault() @@ -153,10 +197,24 @@ export function ChatSidebar({ document.addEventListener('mouseup', handleMouseUp) }, [width]) - // Auto-focus input when sidebar opens + // Auto-focus textarea when sidebar opens and auto-populate with current file if applicable useEffect(() => { - inputRef.current?.focus() - }, []) + textareaRef.current?.focus() + + // Auto-populate with @currentfile if opening from a knowledge file + if (selectedPath && selectedPath.startsWith('knowledge/') && selectedPath.endsWith('.md')) { + // Only auto-populate if there's no existing message + if (!message.trim()) { + const displayName = wikiLabel(selectedPath) + onMessageChange(`@${displayName} `) + setMentions([{ + id: `mention-auto-${Date.now()}`, + path: selectedPath, + displayName, + }]) + } + } + }, []) // eslint-disable-line react-hooks/exhaustive-deps const hasConversation = conversation.length > 0 || currentAssistantMessage || currentReasoning const canSubmit = Boolean(message.trim()) && !isProcessing @@ -164,14 +222,27 @@ export function ChatSidebar({ const handleSubmit = () => { const trimmed = message.trim() if (trimmed && !isProcessing) { - onSubmit({ text: trimmed, files: [] }) + onSubmit({ text: trimmed, files: [] }, mentions) + setMentions([]) } } const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - handleSubmit() + // If mention popover is open, let it handle navigation keys + if (activeMention && ['ArrowDown', 'ArrowUp', 'Tab', 'Escape'].includes(e.key)) { + return + } + + if (e.key === 'Enter') { + // If mention popover is open, Enter should select the item + if (activeMention) { + return + } + + if (!e.shiftKey) { + e.preventDefault() + handleSubmit() + } } } @@ -304,24 +375,25 @@ export function ChatSidebar({ {/* Input area - responsive to sidebar width, matches floating bar position exactly */} - - - + + onMessageChange(e.target.value)} onKeyDown={handleKeyDown} placeholder="Ask anything..." disabled={isProcessing} - className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground disabled:opacity-50" + rows={1} + className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground disabled:opacity-50 resize-none max-h-32 min-h-[1.5rem]" + style={{ fieldSizing: 'content' } as React.CSSProperties} /> + {knowledgeFiles.length > 0 && ( + + )} diff --git a/apps/x/apps/renderer/src/components/mention-popover.tsx b/apps/x/apps/renderer/src/components/mention-popover.tsx new file mode 100644 index 00000000..6deb7064 --- /dev/null +++ b/apps/x/apps/renderer/src/components/mention-popover.tsx @@ -0,0 +1,152 @@ +import { useMemo, useEffect, useState, useCallback } from 'react' +import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' +import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command' +import { wikiLabel, stripKnowledgePrefix } from '@/lib/wiki-links' +import { FileTextIcon } from 'lucide-react' +import type { CaretCoordinates } from '@/lib/textarea-caret' + +interface MentionPopoverProps { + files: string[] + query: string + position: CaretCoordinates | null + containerRef: React.RefObject + onSelect: (path: string, displayName: string) => void + onClose: () => void + open: boolean +} + +const MAX_VISIBLE_FILES = 8 + +export function MentionPopover({ + files, + query, + position, + containerRef, + onSelect, + onClose, + open, +}: MentionPopoverProps) { + const [selectedIndex, setSelectedIndex] = useState(0) + + // Filter files based on query + const filteredFiles = useMemo(() => { + if (!query) return files.slice(0, MAX_VISIBLE_FILES) + + const lowerQuery = query.toLowerCase() + return files + .filter((path) => { + const label = wikiLabel(path).toLowerCase() + const normalized = stripKnowledgePrefix(path).toLowerCase() + return label.includes(lowerQuery) || normalized.includes(lowerQuery) + }) + .slice(0, MAX_VISIBLE_FILES) + }, [files, query]) + + // Reset selection when filtered list changes + useEffect(() => { + setSelectedIndex(0) + }, [filteredFiles.length, query]) + + // Handle keyboard navigation + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!open) return + + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + e.stopPropagation() + setSelectedIndex((prev) => (prev + 1) % filteredFiles.length) + break + case 'ArrowUp': + e.preventDefault() + e.stopPropagation() + setSelectedIndex((prev) => (prev - 1 + filteredFiles.length) % filteredFiles.length) + break + case 'Enter': + e.preventDefault() + e.stopPropagation() + if (filteredFiles[selectedIndex]) { + const path = filteredFiles[selectedIndex] + onSelect(path, wikiLabel(path)) + } + break + case 'Escape': + e.preventDefault() + e.stopPropagation() + onClose() + break + case 'Tab': + e.preventDefault() + e.stopPropagation() + if (filteredFiles[selectedIndex]) { + const path = filteredFiles[selectedIndex] + onSelect(path, wikiLabel(path)) + } + break + } + }, + [open, filteredFiles, selectedIndex, onSelect, onClose] + ) + + // Attach keyboard listener + useEffect(() => { + if (!open) return + + // Use capture phase to intercept before textarea handles it + document.addEventListener('keydown', handleKeyDown, true) + return () => { + document.removeEventListener('keydown', handleKeyDown, true) + } + }, [open, handleKeyDown]) + + if (!open || !position || filteredFiles.length === 0) { + return null + } + + return ( + !isOpen && onClose()}> + + + + e.preventDefault()} + onCloseAutoFocus={(e) => e.preventDefault()} + > + + + {filteredFiles.length === 0 ? ( + No files found + ) : ( + filteredFiles.map((path, index) => ( + onSelect(path, wikiLabel(path))} + className={index === selectedIndex ? 'bg-accent' : ''} + onMouseEnter={() => setSelectedIndex(index)} + > + + {wikiLabel(path)} + + )) + )} + + + + + ) +} diff --git a/apps/x/apps/renderer/src/components/ui/input-group.tsx b/apps/x/apps/renderer/src/components/ui/input-group.tsx index 3d1f9d92..6a2767e7 100644 --- a/apps/x/apps/renderer/src/components/ui/input-group.tsx +++ b/apps/x/apps/renderer/src/components/ui/input-group.tsx @@ -144,12 +144,13 @@ function InputGroupInput({ ) } -function InputGroupTextarea({ - className, - ...props -}: React.ComponentProps<"textarea">) { +const InputGroupTextarea = React.forwardRef< + HTMLTextAreaElement, + React.ComponentProps<"textarea"> +>(({ className, ...props }, ref) => { return ( ) -} +}) +InputGroupTextarea.displayName = "InputGroupTextarea" export { InputGroup, diff --git a/apps/x/apps/renderer/src/hooks/use-mention-detection.ts b/apps/x/apps/renderer/src/hooks/use-mention-detection.ts new file mode 100644 index 00000000..83a90438 --- /dev/null +++ b/apps/x/apps/renderer/src/hooks/use-mention-detection.ts @@ -0,0 +1,111 @@ +import { useState, useEffect, useCallback, type RefObject } from 'react' +import { getCaretCoordinates, type CaretCoordinates } from '@/lib/textarea-caret' + +export interface ActiveMention { + query: string + triggerIndex: number +} + +export interface UseMentionDetectionResult { + activeMention: ActiveMention | null + cursorCoords: CaretCoordinates | null +} + +/** + * Hook that detects when a user types @ in a textarea and provides + * the query string and cursor coordinates for showing a mention popover. + */ +export function useMentionDetection( + textareaRef: RefObject, + value: string, + enabled: boolean +): UseMentionDetectionResult { + const [activeMention, setActiveMention] = useState(null) + const [cursorCoords, setCursorCoords] = useState(null) + + const detectMention = useCallback(() => { + if (!enabled) { + setActiveMention(null) + setCursorCoords(null) + return + } + + const textarea = textareaRef.current + if (!textarea) { + setActiveMention(null) + setCursorCoords(null) + return + } + + const cursorPos = textarea.selectionStart + const textBeforeCursor = value.substring(0, cursorPos) + + // Find the last @ symbol before cursor + const lastAtIndex = textBeforeCursor.lastIndexOf('@') + + if (lastAtIndex === -1) { + setActiveMention(null) + setCursorCoords(null) + return + } + + // Check if @ is the start of an email (has non-whitespace before it) + if (lastAtIndex > 0) { + const charBefore = textBeforeCursor[lastAtIndex - 1] + // If char before @ is not whitespace or newline, it's likely an email + if (charBefore && !/[\s\n]/.test(charBefore)) { + setActiveMention(null) + setCursorCoords(null) + return + } + } + + // Get text between @ and cursor + const textAfterAt = textBeforeCursor.substring(lastAtIndex + 1) + + // If there's a space or newline after @, the mention is closed + if (/[\s\n]/.test(textAfterAt)) { + setActiveMention(null) + setCursorCoords(null) + return + } + + // We have an active mention + const query = textAfterAt + setActiveMention({ + query, + triggerIndex: lastAtIndex, + }) + + // Calculate cursor coordinates + const coords = getCaretCoordinates(textarea, lastAtIndex) + setCursorCoords(coords) + }, [textareaRef, value, enabled]) + + // Detect mention on value or cursor position change + useEffect(() => { + detectMention() + }, [detectMention]) + + // Also detect on selection change (cursor movement) + useEffect(() => { + const textarea = textareaRef.current + if (!textarea || !enabled) return + + const handleSelectionChange = () => { + detectMention() + } + + // Listen for selection changes + document.addEventListener('selectionchange', handleSelectionChange) + + return () => { + document.removeEventListener('selectionchange', handleSelectionChange) + } + }, [textareaRef, enabled, detectMention]) + + return { + activeMention, + cursorCoords, + } +} diff --git a/apps/x/apps/renderer/src/lib/textarea-caret.ts b/apps/x/apps/renderer/src/lib/textarea-caret.ts new file mode 100644 index 00000000..ebffd063 --- /dev/null +++ b/apps/x/apps/renderer/src/lib/textarea-caret.ts @@ -0,0 +1,121 @@ +/** + * Get the pixel coordinates of a position within a textarea. + * Uses the mirror div technique to calculate cursor position. + */ + +// Properties that affect text layout and must be copied to the mirror div +const PROPERTIES_TO_COPY = [ + 'direction', + 'boxSizing', + 'width', + 'height', + 'overflowX', + 'overflowY', + 'borderTopWidth', + 'borderRightWidth', + 'borderBottomWidth', + 'borderLeftWidth', + 'borderStyle', + 'paddingTop', + 'paddingRight', + 'paddingBottom', + 'paddingLeft', + 'fontStyle', + 'fontVariant', + 'fontWeight', + 'fontStretch', + 'fontSize', + 'fontSizeAdjust', + 'lineHeight', + 'fontFamily', + 'textAlign', + 'textTransform', + 'textIndent', + 'textDecoration', + 'letterSpacing', + 'wordSpacing', + 'tabSize', + 'MozTabSize', +] as const + +export interface CaretCoordinates { + top: number + left: number + height: number +} + +export function getCaretCoordinates( + textarea: HTMLTextAreaElement, + position: number +): CaretCoordinates { + // Create a mirror div to measure text position + const div = document.createElement('div') + div.id = 'textarea-caret-position-mirror-div' + document.body.appendChild(div) + + const style = div.style + const computed = window.getComputedStyle(textarea) + + // Default return value + const defaultCoords: CaretCoordinates = { top: 0, left: 0, height: 0 } + + // Position offscreen + style.whiteSpace = 'pre-wrap' + style.wordWrap = 'break-word' + style.position = 'absolute' + style.visibility = 'hidden' + style.overflow = 'hidden' + + // Copy styles from textarea to mirror div + for (const prop of PROPERTIES_TO_COPY) { + style[prop as keyof CSSStyleDeclaration] = computed[prop as keyof CSSStyleDeclaration] as string + } + + // Firefox-specific handling + const isFirefox = navigator.userAgent.toLowerCase().includes('firefox') + if (isFirefox) { + if (textarea.scrollHeight > parseInt(computed.height)) { + style.overflowY = 'scroll' + } + } else { + style.overflow = 'hidden' + } + + // Set the text content up to the position + div.textContent = textarea.value.substring(0, position) + + // Create a span at the cursor position + const span = document.createElement('span') + // Add a zero-width space to ensure the span has height + span.textContent = textarea.value.substring(position) || '\u200B' + div.appendChild(span) + + try { + const coordinates: CaretCoordinates = { + top: span.offsetTop + parseInt(computed.borderTopWidth) - textarea.scrollTop, + left: span.offsetLeft + parseInt(computed.borderLeftWidth) - textarea.scrollLeft, + height: parseInt(computed.lineHeight) || parseInt(computed.fontSize) * 1.2, + } + + return coordinates + } finally { + document.body.removeChild(div) + } +} + +/** + * Get absolute coordinates relative to the viewport + */ +export function getCaretAbsoluteCoordinates( + textarea: HTMLTextAreaElement, + position: number +): CaretCoordinates { + const relative = getCaretCoordinates(textarea, position) + const rect = textarea.getBoundingClientRect() + + return { + top: rect.top + relative.top, + left: rect.left + relative.left, + height: relative.height, + } +}