diff --git a/src/components/modules/comment/CommentBox/UniversalTextArea.tsx b/src/components/modules/comment/CommentBox/UniversalTextArea.tsx index 56a723be66..80441cfb5e 100644 --- a/src/components/modules/comment/CommentBox/UniversalTextArea.tsx +++ b/src/components/modules/comment/CommentBox/UniversalTextArea.tsx @@ -8,6 +8,7 @@ import { useIsMobile } from '~/atoms/hooks' import { FloatPopover } from '~/components/ui/float-popover' import { TextArea } from '~/components/ui/input' import { useRefValue } from '~/hooks/common/use-ref-value' +import { scrollTextareaToCursor } from '~/lib/dom' import { getRandomPlaceholder } from './constants' import { @@ -26,29 +27,59 @@ export const UniversalTextArea: Component = ({ className }) => { const value = useCommentBoxTextValue() const taRef = useRef(null) - const handleInsertEmoji = useCallback((emoji: string) => { - if (!taRef.current) { - return - } + const handleInsertEmoji = useCallback( + (emoji: string) => { + if (!taRef.current) { + return + } - const $ta = taRef.current - const start = $ta.selectionStart - const end = $ta.selectionEnd - - $ta.value = `${$ta.value.substring( - 0, - start, - )} ${emoji} ${$ta.value.substring(end, $ta.value.length)}` - - setter('text', $ta.value) - requestAnimationFrame(() => { - const shouldMoveToPos = start + emoji.length + 2 - $ta.selectionStart = shouldMoveToPos - $ta.selectionEnd = shouldMoveToPos - - $ta.focus() - }) - }, []) + const $ta = taRef.current + const start = $ta.selectionStart + const end = $ta.selectionEnd + + $ta.value = `${$ta.value.substring( + 0, + start, + )} ${emoji} ${$ta.value.substring(end, $ta.value.length)}` + + setter('text', $ta.value) + requestAnimationFrame(() => { + const shouldMoveToPos = start + emoji.length + 2 + $ta.selectionStart = shouldMoveToPos + $ta.selectionEnd = shouldMoveToPos + + $ta.focus() + }) + }, + [setter], + ) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey) { + e.preventDefault() + const $ta = taRef.current + if ($ta) { + const start = $ta.selectionStart + const end = $ta.selectionEnd + const textBefore = $ta.value.substring(0, start) + const textAfter = $ta.value.substring(end) + $ta.value = `${textBefore}\n\n${textAfter}` + setter('text', $ta.value) + + requestAnimationFrame(() => { + const shouldMoveToPos = start + 2 + $ta.selectionStart = shouldMoveToPos + $ta.selectionEnd = shouldMoveToPos + $ta.focus() + // 上面设置的光标,可能不在可见区域内,因此 scroll 到光标所在位置 + scrollTextareaToCursor(taRef) + }) + } + } + }, + [setter], + ) useEffect(() => { const $ta = taRef.current @@ -80,6 +111,7 @@ export const UniversalTextArea: Component = ({ className }) => { wrapperClassName={className} ref={taRef} defaultValue={value} + onKeyDown={handleKeyDown} onChange={(e) => setter('text', e.target.value)} placeholder={placeholder} onCmdEnter={(e) => { diff --git a/src/components/ui/input/TextArea.tsx b/src/components/ui/input/TextArea.tsx index 858d342aca..0292b30c41 100644 --- a/src/components/ui/input/TextArea.tsx +++ b/src/components/ui/input/TextArea.tsx @@ -42,6 +42,7 @@ export const TextArea = forwardRef< rounded = 'xl', bordered = true, onCmdEnter, + onKeyDown, ...rest } = props const mouseX = useMotionValue(0) @@ -54,9 +55,20 @@ export const TextArea = forwardRef< }, [mouseX, mouseY], ) + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + onCmdEnter?.(e) + } + onKeyDown?.(e) + }, + [onCmdEnter, onKeyDown], + ) const background = useMotionTemplate`radial-gradient(320px circle at ${mouseX}px ${mouseY}px, var(--spotlight-color) 0%, transparent 85%)` const isMobile = useIsMobile() - const inputProps = useInputComposition(props) + const inputProps = useInputComposition( + Object.assign({}, props, { onKeyDown: handleKeyDown }), + ) const [isFocus, setIsFocus] = useState(false) return (
{ - if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { - onCmdEnter?.(e) - } - rest.onKeyDown?.(e) - inputProps.onKeyDown?.(e) - }} /> {children} diff --git a/src/hooks/common/use-input-composition.ts b/src/hooks/common/use-input-composition.ts index fe6252acc7..267933aacd 100644 --- a/src/hooks/common/use-input-composition.ts +++ b/src/hooks/common/use-input-composition.ts @@ -36,12 +36,12 @@ export const useInputComposition = ( const handleKeyDown: React.KeyboardEventHandler = useCallback( (e) => { - onKeyDown?.(e) - + // 中文正在输入时,不响应 keydown 事件 if (isCompositionRef.current) { e.stopPropagation() return } + onKeyDown?.(e) }, [onKeyDown], ) diff --git a/src/lib/dom.ts b/src/lib/dom.ts index e6b99eb5e9..f83ab80ab1 100644 --- a/src/lib/dom.ts +++ b/src/lib/dom.ts @@ -1,4 +1,4 @@ -import type { ReactEventHandler } from 'react' +import type { ReactEventHandler, RefObject } from 'react' export const stopPropagation: ReactEventHandler = (e) => e.stopPropagation() @@ -23,3 +23,57 @@ export function escapeSelector(selector: string) { export const nextFrame = (fn: () => void) => requestAnimationFrame(() => requestAnimationFrame(fn)) + +export const textareaStyles = [ + 'font', + 'width', + 'padding', + 'border', + 'boxSizing', + 'whiteSpace', + 'wordWrap', + 'lineHeight', + 'letterSpacing', +] as const +export const scrollTextareaToCursor = ( + taRef: RefObject, +) => { + const $ta = taRef.current + if ($ta) { + const div = document.createElement('div') + const styles = getComputedStyle($ta) + // 复制 textarea 的样式到 div + textareaStyles.forEach((style) => { + div.style[style] = styles[style] + }) + div.style.position = 'absolute' + div.style.top = '-9999px' + div.style.left = '-9999px' + + // 将文本插入到 div 中,并在光标位置添加一个 span + const start = $ta.selectionStart + const end = $ta.selectionEnd + const textBeforeCursor = $ta.value.substring(0, start) + const textAfterCursor = $ta.value.substring(end) + const textBeforeNode = document.createTextNode(textBeforeCursor) + const cursorNode = document.createElement('span') + cursorNode.id = 'cursor' + const textAfterNode = document.createTextNode(textAfterCursor) + + div.appendChild(textBeforeNode) + div.appendChild(cursorNode) + div.appendChild(textAfterNode) + document.body.appendChild(div) + + // 获取光标元素的位置 + const cursorSpan = document.getElementById('cursor') + const cursorY = cursorSpan!.offsetTop + const lineHeight = parseInt(styles.lineHeight) + // 移除临时 div + document.body.removeChild(div) + + // 计算滚动位置 + const scrollTop = cursorY - $ta.clientHeight / 2 + lineHeight / 2 + $ta.scrollTop = Math.max(0, scrollTop) + } +}