💄 context menu upside down
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
import { useState, useRef, type KeyboardEvent } from 'react'
|
import { useState, useRef, type KeyboardEvent } from 'react'
|
||||||
import type { Attachment } from '../../types/editor'
|
import type { Attachment } from '../../types/editor'
|
||||||
|
import { ContextMenu } from '../FileTree/ContextMenu'
|
||||||
|
import type { MenuItem } from '../FileTree/ContextMenu'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onSend: (text: string, attachments: Attachment[]) => void
|
onSend: (text: string, attachments: Attachment[]) => void
|
||||||
@@ -15,6 +17,8 @@ function attachmentIcon(mimeType: string): string {
|
|||||||
export function ChatInput({ onSend, disabled }: Props): JSX.Element {
|
export function ChatInput({ onSend, disabled }: Props): JSX.Element {
|
||||||
const [value, setValue] = useState('')
|
const [value, setValue] = useState('')
|
||||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
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 textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
const submit = (): void => {
|
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)
|
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 (
|
return (
|
||||||
<div className="chat-input-area">
|
<div className="chat-input-area">
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
@@ -107,6 +181,7 @@ export function ChatInput({ onSend, disabled }: Props): JSX.Element {
|
|||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => setValue(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onInput={handleInput}
|
onInput={handleInput}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
placeholder={disabled ? 'Open a chapter first…' : 'Ask about this chapter… (Enter to send)'}
|
placeholder={disabled ? 'Open a chapter first…' : 'Ask about this chapter… (Enter to send)'}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
rows={1}
|
rows={1}
|
||||||
@@ -120,6 +195,14 @@ export function ChatInput({ onSend, disabled }: Props): JSX.Element {
|
|||||||
↑
|
↑
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{menuPos && (
|
||||||
|
<ContextMenu
|
||||||
|
x={menuPos.x}
|
||||||
|
y={menuPos.y}
|
||||||
|
items={chatInputMenuItems}
|
||||||
|
onClose={() => setMenuPos(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import './ContextMenu.css'
|
import './ContextMenu.css'
|
||||||
|
|
||||||
@@ -16,8 +16,25 @@ interface Props {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MARGIN = 6 // min gap from viewport edges (px)
|
||||||
|
|
||||||
export function ContextMenu({ x, y, items, onClose }: Props): JSX.Element {
|
export function ContextMenu({ x, y, items, onClose }: Props): JSX.Element {
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
function handlePointerDown(e: MouseEvent): void {
|
function handlePointerDown(e: MouseEvent): void {
|
||||||
@@ -37,7 +54,11 @@ export function ContextMenu({ x, y, items, onClose }: Props): JSX.Element {
|
|||||||
}, [onClose])
|
}, [onClose])
|
||||||
|
|
||||||
return createPortal(
|
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) =>
|
{items.map((item, i) =>
|
||||||
item === 'separator' ? (
|
item === 'separator' ? (
|
||||||
<div key={i} className="context-menu-separator" />
|
<div key={i} className="context-menu-separator" />
|
||||||
|
|||||||
Reference in New Issue
Block a user