💄 context menu upside down

This commit is contained in:
2026-02-26 11:27:08 +10:00
parent cce3d0de7e
commit 525f795447
2 changed files with 106 additions and 2 deletions

View File

@@ -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<Attachment[]>([])
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null)
const [hasSelection, setHasSelection] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>): 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 (
<div className="chat-input-area">
{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 {
</button>
</div>
{menuPos && (
<ContextMenu
x={menuPos.x}
y={menuPos.y}
items={chatInputMenuItems}
onClose={() => setMenuPos(null)}
/>
)}
</div>
)
}

View File

@@ -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<HTMLDivElement>(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(
<div className="context-menu" style={{ left: x, top: y }} ref={ref}>
<div
className="context-menu"
style={{ left: adjPos.x, top: adjPos.y, visibility: ready ? 'visible' : 'hidden' }}
ref={ref}
>
{items.map((item, i) =>
item === 'separator' ? (
<div key={i} className="context-menu-separator" />