From 525f795447ee9d65c9dc41bbd41a59b9d6252234 Mon Sep 17 00:00:00 2001 From: Alex Hernandez Date: Thu, 26 Feb 2026 11:27:08 +1000 Subject: [PATCH] :lipstick: context menu upside down --- src/renderer/components/AIChat/ChatInput.tsx | 83 +++++++++++++++++++ .../components/FileTree/ContextMenu.tsx | 25 +++++- 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/src/renderer/components/AIChat/ChatInput.tsx b/src/renderer/components/AIChat/ChatInput.tsx index e71ec3d..9c782dc 100644 --- a/src/renderer/components/AIChat/ChatInput.tsx +++ b/src/renderer/components/AIChat/ChatInput.tsx @@ -1,5 +1,7 @@ import { useState, useRef, type KeyboardEvent } from 'react' import type { Attachment } from '../../types/editor' +import { ContextMenu } from '../FileTree/ContextMenu' +import type { MenuItem } from '../FileTree/ContextMenu' interface Props { onSend: (text: string, attachments: Attachment[]) => void @@ -15,6 +17,8 @@ function attachmentIcon(mimeType: string): string { export function ChatInput({ onSend, disabled }: Props): JSX.Element { const [value, setValue] = useState('') const [attachments, setAttachments] = useState([]) + const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null) + const [hasSelection, setHasSelection] = useState(false) const textareaRef = useRef(null) const submit = (): void => { @@ -60,6 +64,76 @@ export function ChatInput({ onSend, disabled }: Props): JSX.Element { const canSend = !disabled && (value.trim().length > 0 || attachments.length > 0) + function handleContextMenu(e: React.MouseEvent): void { + if (disabled) return + e.preventDefault() + const el = textareaRef.current + setHasSelection(!!el && el.selectionStart !== el.selectionEnd) + setMenuPos({ x: e.clientX, y: e.clientY }) + } + + const chatInputMenuItems: (MenuItem | 'separator')[] = [ + { + label: 'Cut', + disabled: !hasSelection, + action: () => { + const el = textareaRef.current + if (!el) return + const { selectionStart: start, selectionEnd: end } = el + if (start === end) return + navigator.clipboard.writeText(value.slice(start, end)).catch(console.error) + const next = value.slice(0, start) + value.slice(end) + setValue(next) + requestAnimationFrame(() => { + el.selectionStart = el.selectionEnd = start + el.style.height = 'auto' + el.style.height = `${Math.min(el.scrollHeight, 160)}px` + el.focus() + }) + } + }, + { + label: 'Copy', + disabled: !hasSelection, + action: () => { + const el = textareaRef.current + if (!el) return + const { selectionStart: start, selectionEnd: end } = el + if (start === end) return + navigator.clipboard.writeText(value.slice(start, end)).catch(console.error) + el.focus() + } + }, + { + label: 'Paste', + action: () => { + const el = textareaRef.current + if (!el) return + navigator.clipboard.readText().then((text) => { + const { selectionStart: start, selectionEnd: end } = el + const next = value.slice(0, start) + text + value.slice(end) + setValue(next) + requestAnimationFrame(() => { + el.selectionStart = el.selectionEnd = start + text.length + el.style.height = 'auto' + el.style.height = `${Math.min(el.scrollHeight, 160)}px` + el.focus() + }) + }).catch(console.error) + } + }, + 'separator', + { + label: 'Select All', + action: () => { + const el = textareaRef.current + if (!el) return + el.select() + setHasSelection(el.value.length > 0) + } + } + ] + return (
{attachments.length > 0 && ( @@ -107,6 +181,7 @@ export function ChatInput({ onSend, disabled }: Props): JSX.Element { onChange={(e) => setValue(e.target.value)} onKeyDown={handleKeyDown} onInput={handleInput} + onContextMenu={handleContextMenu} placeholder={disabled ? 'Open a chapter first…' : 'Ask about this chapter… (Enter to send)'} disabled={disabled} rows={1} @@ -120,6 +195,14 @@ export function ChatInput({ onSend, disabled }: Props): JSX.Element { ↑
+ {menuPos && ( + setMenuPos(null)} + /> + )} ) } diff --git a/src/renderer/components/FileTree/ContextMenu.tsx b/src/renderer/components/FileTree/ContextMenu.tsx index ff14d3e..ab0666f 100644 --- a/src/renderer/components/FileTree/ContextMenu.tsx +++ b/src/renderer/components/FileTree/ContextMenu.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import './ContextMenu.css' @@ -16,8 +16,25 @@ interface Props { onClose: () => void } +const MARGIN = 6 // min gap from viewport edges (px) + export function ContextMenu({ x, y, items, onClose }: Props): JSX.Element { const ref = useRef(null) + const [adjPos, setAdjPos] = useState({ x, y }) + const [ready, setReady] = useState(false) + + // After the menu is in the DOM, measure it and clamp so it stays on screen. + // useLayoutEffect runs synchronously before the browser paints, so the menu + // is never visible in the wrong position. + useLayoutEffect(() => { + const el = ref.current + if (!el) return + const { width, height } = el.getBoundingClientRect() + const adjX = Math.max(MARGIN, Math.min(x, window.innerWidth - width - MARGIN)) + const adjY = Math.max(MARGIN, Math.min(y, window.innerHeight - height - MARGIN)) + setAdjPos({ x: adjX, y: adjY }) + setReady(true) + }, [x, y]) useEffect(() => { function handlePointerDown(e: MouseEvent): void { @@ -37,7 +54,11 @@ export function ContextMenu({ x, y, items, onClose }: Props): JSX.Element { }, [onClose]) return createPortal( -
+
{items.map((item, i) => item === 'separator' ? (