diff --git a/src/components/InputBar.tsx b/src/components/InputBar.tsx index c12acf2d..2662a2a0 100644 --- a/src/components/InputBar.tsx +++ b/src/components/InputBar.tsx @@ -1,4 +1,4 @@ -import { useRef, useEffect, useCallback, useState, useMemo, type ReactNode } from 'react' +import { Fragment, useRef, useEffect, useCallback, useState, useMemo, type ReactNode } from 'react' import { createPortal } from 'react-dom' import { useStore, submitTask, addImageFromFile, updateTaskInStore, removeMultipleTasks, getCachedImage, ensureImageCached } from '../store' import { DEFAULT_PARAMS } from '../types' @@ -12,6 +12,9 @@ import { getSafeBoundingClientRect } from '../lib/domRect' import Select from './Select' import SizePickerModal from './SizePickerModal' import ViewportTooltip from './ViewportTooltip' +import { useCloseOnEscape } from '../hooks/useCloseOnEscape' +import { usePreventBackgroundScroll } from '../hooks/usePreventBackgroundScroll' +import { useHintTooltip } from '../hooks/useHintTooltip' function getMentionTagTextLength(el: Element) { @@ -250,14 +253,17 @@ function ButtonTooltip({ visible, text }: { visible: boolean; text: ReactNode }) if (!visible) return null return ( - - {text} - + + + {text} + + ) } /** API 支持的最大参考图数量 */ const API_MAX_IMAGES = 16 +const MOBILE_INPUT_ROW_H = 32 function useIsMobile() { const [isMobile, setIsMobile] = useState(window.innerWidth < 640) @@ -269,6 +275,67 @@ function useIsMobile() { return isMobile } +const KEYBOARD_THRESHOLD = 0.75 + +function useKeyboardVisible() { + const [visible, setVisible] = useState(false) + useEffect(() => { + let fromViewport = false + let fromFocus = false + const baseHeight = window.screen?.height || window.innerHeight + let focusOutTimer: ReturnType | null = null + const update = () => setVisible(fromViewport || fromFocus) + + const vv = window.visualViewport + const onVVResize = () => { + if (vv) { + fromViewport = vv.height < baseHeight * KEYBOARD_THRESHOLD + update() + } + } + + const onWindowResize = () => { + const curr = window.innerHeight + if (curr > baseHeight * 0.85) { + fromFocus = false + update() + } else if (curr < baseHeight * KEYBOARD_THRESHOLD) { + fromFocus = true + update() + } + } + + const onFocusIn = (e: FocusEvent) => { + const t = e.target as HTMLElement | null + if (t && (t.isContentEditable || t.tagName === 'INPUT' || t.tagName === 'TEXTAREA')) { + fromFocus = true + update() + } + } + const onFocusOut = (e: FocusEvent) => { + const related = e.relatedTarget as HTMLElement | null + if (related && (related.isContentEditable || related.tagName === 'INPUT' || related.tagName === 'TEXTAREA')) { + return + } + fromFocus = false + focusOutTimer = setTimeout(update, 100) + } + + if (vv) vv.addEventListener('resize', onVVResize) + window.addEventListener('resize', onWindowResize) + document.addEventListener('focusin', onFocusIn) + document.addEventListener('focusout', onFocusOut) + return () => { + if (vv) vv.removeEventListener('resize', onVVResize) + window.removeEventListener('resize', onWindowResize) + document.removeEventListener('focusin', onFocusIn) + document.removeEventListener('focusout', onFocusOut) + if (focusOutTimer) clearTimeout(focusOutTimer) + } + }, []) + return visible +} + export default function InputBar() { const prompt = useStore((s) => s.prompt) const setPrompt = useStore((s) => s.setPrompt) @@ -401,6 +468,7 @@ export default function InputBar() { const moveInputImage = useStore((s) => s.moveInputImage) const fileInputRef = useRef(null) + const cameraInputRef = useRef(null) const textareaRef = useRef(null) const cardRef = useRef(null) const imagesRef = useRef(null) @@ -409,13 +477,11 @@ export default function InputBar() { const [isDragging, setIsDragging] = useState(false) const [submitHover, setSubmitHover] = useState(false) const [attachHover, setAttachHover] = useState(false) - const [compressionHintVisible, setCompressionHintVisible] = useState(false) - const [moderationHintVisible, setModerationHintVisible] = useState(false) - const [sizeHintVisible, setSizeHintVisible] = useState(false) - const [qualityHintVisible, setQualityHintVisible] = useState(false) const [imageHintId, setImageHintId] = useState(null) const [mobileCollapsed, setMobileCollapsed] = useState(false) const [showSizePicker, setShowSizePicker] = useState(false) + const [showParamsModal, setShowParamsModal] = useState(false) + const [mobileParamSheet, setMobileParamSheet] = useState<'quality' | 'format' | 'moderation' | null>(null) const [maskPreviewUrl, setMaskPreviewUrl] = useState('') const [imageDragIndex, setImageDragIndex] = useState(null) const [imageDragOverIndex, setImageDragOverIndex] = useState(null) @@ -435,20 +501,32 @@ export default function InputBar() { const [cursorPos, setCursorPos] = useState(0) const [menuLeft, setMenuLeft] = useState(0) const maskConflictNoticeShownRef = useRef(false) - const compressionHintTimerRef = useRef(null) - const moderationHintTimerRef = useRef(null) - const sizeHintTimerRef = useRef(null) - const qualityHintTimerRef = useRef(null) const imageHintTimerRef = useRef(null) - const nLimitHintTimerRef = useRef(null) const [outputCompressionInput, setOutputCompressionInput] = useState( params.output_compression == null ? '' : String(params.output_compression), ) const [nInput, setNInput] = useState(String(params.n)) const [nInputFocused, setNInputFocused] = useState(false) - const [nLimitHintVisible, setNLimitHintVisible] = useState(false) const dragCounter = useRef(0) const isMobile = useIsMobile() + const keyboardVisible = useKeyboardVisible() + + useEffect(() => { + if (!isMobile) return + const onTouchStart = (e: TouchEvent) => { + const target = e.target as HTMLElement + if (target.closest('[data-input-bar]')) return + const active = document.activeElement as HTMLElement | null + if (active && (active.isContentEditable || active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) { + active.blur() + } + } + document.addEventListener('touchstart', onTouchStart, { passive: true }) + return () => document.removeEventListener('touchstart', onTouchStart) + }, [isMobile]) + + useCloseOnEscape(showParamsModal, () => setShowParamsModal(false)) + usePreventBackgroundScroll(showParamsModal) const currentActiveProfile = useMemo(() => getActiveApiProfile(settings), [settings]) const activeProfile = useMemo(() => ( @@ -488,6 +566,13 @@ export default function InputBar() { { label: 'high', value: 'high' }, ] const atImageLimit = inputImages.length >= API_MAX_IMAGES + + const compressionHint = useHintTooltip() + const moderationHint = useHintTooltip({ enabled: () => moderationDisabled }) + const sizeHint = useHintTooltip({ enabled: () => isFalTextToImage }) + const qualityHint = useHintTooltip({ enabled: () => settings.codexCli || isFalProvider }) + const nLimitHint = useHintTooltip({ autoHideMs: 2000 }) + const maskTargetImage = maskDraft ? inputImages.find((img) => img.id === maskDraft.targetImageId) ?? null : null @@ -566,25 +651,10 @@ export default function InputBar() { }, [inputImages.length, params, effectiveSettings, setParams]) useEffect(() => () => { - if (compressionHintTimerRef.current != null) { - window.clearTimeout(compressionHintTimerRef.current) - } - if (moderationHintTimerRef.current != null) { - window.clearTimeout(moderationHintTimerRef.current) - } - if (qualityHintTimerRef.current != null) { - window.clearTimeout(qualityHintTimerRef.current) - } - if (sizeHintTimerRef.current != null) { - window.clearTimeout(sizeHintTimerRef.current) - } if (imageHintTimerRef.current != null) { window.clearTimeout(imageHintTimerRef.current) } imageHintReleaseRef.current?.() - if (nLimitHintTimerRef.current != null) { - window.clearTimeout(nLimitHintTimerRef.current) - } }, []) useEffect(() => { @@ -625,47 +695,24 @@ export default function InputBar() { }, [outputCompressionInput, params.output_compression, setParams]) const commitN = useCallback(() => { - setNLimitHintVisible(false) - if (nLimitHintTimerRef.current != null) { - window.clearTimeout(nLimitHintTimerRef.current) - nLimitHintTimerRef.current = null - } + nLimitHint.hide() const nextValue = Number(nInput) const normalizedValue = nInput.trim() === '' ? DEFAULT_PARAMS.n : Number.isNaN(nextValue) ? params.n : nextValue const clampedValue = Math.min(outputImageLimit, Math.max(1, normalizedValue)) setNInput(String(clampedValue)) setParams({ n: clampedValue }) - }, [nInput, outputImageLimit, params.n, setParams]) - - const showNLimitHint = useCallback(() => { - setNLimitHintVisible(true) - if (nLimitHintTimerRef.current != null) { - window.clearTimeout(nLimitHintTimerRef.current) - } - nLimitHintTimerRef.current = window.setTimeout(() => { - setNLimitHintVisible(false) - nLimitHintTimerRef.current = null - }, 2000) - }, []) - - const hideNLimitHint = useCallback(() => { - setNLimitHintVisible(false) - if (nLimitHintTimerRef.current != null) { - window.clearTimeout(nLimitHintTimerRef.current) - nLimitHintTimerRef.current = null - } - }, []) + }, [nInput, outputImageLimit, params.n, setParams, nLimitHint]) const handleNInputChange = useCallback((value: string) => { setNInput(value) const nextValue = Number(value) if (!Number.isNaN(nextValue) && nextValue > outputImageLimit) { - showNLimitHint() + nLimitHint.show() } else { - hideNLimitHint() + nLimitHint.hide() } - }, [hideNLimitHint, outputImageLimit, showNLimitHint]) + }, [outputImageLimit, nLimitHint]) const handleNLimitIncreaseAttempt = useCallback((preventDefault: () => void) => { const currentValue = Number(nInput) @@ -673,101 +720,8 @@ export default function InputBar() { if (!nInputFocused || effectiveValue < outputImageLimit) return preventDefault() - showNLimitHint() - }, [nInput, nInputFocused, outputImageLimit, params.n, showNLimitHint]) - - const showModerationHint = () => { - if (moderationDisabled) setModerationHintVisible(true) - } - - const hideModerationHint = () => { - setModerationHintVisible(false) - clearModerationHintTimer() - } - - const clearModerationHintTimer = () => { - if (moderationHintTimerRef.current != null) { - window.clearTimeout(moderationHintTimerRef.current) - moderationHintTimerRef.current = null - } - } - - const startModerationHintTouch = () => { - if (!moderationDisabled) return - moderationHintTimerRef.current = window.setTimeout(() => { - setModerationHintVisible(true) - moderationHintTimerRef.current = null - }, 450) - } - - const showCompressionHint = () => setCompressionHintVisible(true) - - const hideCompressionHint = () => { - setCompressionHintVisible(false) - clearCompressionHintTimer() - } - - const clearCompressionHintTimer = () => { - if (compressionHintTimerRef.current != null) { - window.clearTimeout(compressionHintTimerRef.current) - compressionHintTimerRef.current = null - } - } - - const startCompressionHintTouch = () => { - compressionHintTimerRef.current = window.setTimeout(() => { - setCompressionHintVisible(true) - compressionHintTimerRef.current = null - }, 450) - } - - const showQualityHint = () => { - if (settings.codexCli || isFalProvider) setQualityHintVisible(true) - } - - const showSizeHint = () => { - if (isFalTextToImage) setSizeHintVisible(true) - } - - const hideSizeHint = () => { - setSizeHintVisible(false) - clearSizeHintTimer() - } - - const clearSizeHintTimer = () => { - if (sizeHintTimerRef.current != null) { - window.clearTimeout(sizeHintTimerRef.current) - sizeHintTimerRef.current = null - } - } - - const startSizeHintTouch = () => { - if (!isFalTextToImage) return - sizeHintTimerRef.current = window.setTimeout(() => { - setSizeHintVisible(true) - sizeHintTimerRef.current = null - }, 450) - } - - const hideQualityHint = () => { - setQualityHintVisible(false) - clearQualityHintTimer() - } - - const clearQualityHintTimer = () => { - if (qualityHintTimerRef.current != null) { - window.clearTimeout(qualityHintTimerRef.current) - qualityHintTimerRef.current = null - } - } - - const startQualityHintTouch = () => { - if (!settings.codexCli && !isFalProvider) return - qualityHintTimerRef.current = window.setTimeout(() => { - setQualityHintVisible(true) - qualityHintTimerRef.current = null - }, 450) - } + nLimitHint.show() + }, [nInput, nInputFocused, outputImageLimit, params.n, nLimitHint]) const clearImageHintTimer = () => { if (imageHintTimerRef.current != null) { @@ -1017,21 +971,33 @@ export default function InputBar() { el.style.height = '0' el.style.overflowY = 'hidden' const scrollH = el.scrollHeight - const minH = 42 + const minH = isMobile ? MOBILE_INPUT_ROW_H : 42 const desired = Math.max(scrollH, minH) const targetH = desired > maxH ? maxH : desired - // 2. 将高度设回上一次的实际高度,强制重绘,准备开始动画 + // 2. 如果元素刚挂载(无历史高度),跳过动画直接设置 + if (prevHeightRef.current === 0) { + el.style.height = targetH + 'px' + el.style.overflowY = desired > maxH ? 'auto' : 'hidden' + prevHeightRef.current = targetH + return + } + + // 3. 将高度设回上一次的实际高度,强制重绘,准备开始动画 el.style.height = prevHeightRef.current + 'px' void el.offsetHeight - // 3. 恢复平滑过渡,并设置目标高度 + // 4. 恢复平滑过渡,并设置目标高度 el.style.transition = 'height 150ms ease, border-color 200ms, box-shadow 200ms' el.style.height = targetH + 'px' el.style.overflowY = desired > maxH ? 'auto' : 'hidden' prevHeightRef.current = targetH - }, []) + }, [isMobile]) + + useEffect(() => { + prevHeightRef.current = 0 + }, [isMobile]) // 将 prompt 同步渲染到 contentEditable(含胶囊 tag) useEffect(() => { @@ -1053,7 +1019,7 @@ export default function InputBar() { if (el.innerHTML !== html) { el.innerHTML = html } - }, [prompt, inputImages]) + }, [prompt, inputImages, isMobile]) useEffect(() => { adjustTextareaHeight() @@ -1442,7 +1408,7 @@ export default function InputBar() { const renderImageThumbs = () => { return (
-
+
{inputImages.map((img, idx) => renderImageThumb(img, idx))} {renderClearAllButton()}
@@ -1463,35 +1429,12 @@ export default function InputBar() {
-
diff --git a/src/hooks/useHintTooltip.ts b/src/hooks/useHintTooltip.ts new file mode 100644 index 00000000..021e540a --- /dev/null +++ b/src/hooks/useHintTooltip.ts @@ -0,0 +1,54 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { onDismissTooltips } from '../lib/tooltipDismiss' + +interface UseHintTooltipOptions { + enabled?: () => boolean + autoHideMs?: number + touchDelayMs?: number +} + +export function useHintTooltip(options: UseHintTooltipOptions = {}) { + const { autoHideMs, touchDelayMs = 450 } = options + const [visible, setVisible] = useState(false) + const timerRef = useRef(null) + const enabledRef = useRef(options.enabled) + enabledRef.current = options.enabled + + const clearTimer = useCallback(() => { + if (timerRef.current != null) { + window.clearTimeout(timerRef.current) + timerRef.current = null + } + }, []) + + const hide = useCallback(() => { + setVisible(false) + clearTimer() + }, [clearTimer]) + + const show = useCallback(() => { + if (enabledRef.current && !enabledRef.current()) return + clearTimer() + setVisible(true) + if (autoHideMs != null) { + timerRef.current = window.setTimeout(() => { + setVisible(false) + timerRef.current = null + }, autoHideMs) + } + }, [autoHideMs, clearTimer]) + + const startTouch = useCallback(() => { + if (enabledRef.current && !enabledRef.current()) return + clearTimer() + timerRef.current = window.setTimeout(() => { + setVisible(true) + timerRef.current = null + }, touchDelayMs) + }, [touchDelayMs, clearTimer]) + + useEffect(() => () => { clearTimer() }, [clearTimer]) + useEffect(() => onDismissTooltips(hide), [hide]) + + return { visible, show, hide, clearTimer, startTouch } +} diff --git a/src/index.css b/src/index.css index 27b614ec..60cf5d0a 100644 --- a/src/index.css +++ b/src/index.css @@ -296,3 +296,31 @@ input[type="number"] { box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.35); } } + +.scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; +} +.scrollbar-hide::-webkit-scrollbar { + display: none; +} + +.animate-slide-up { + animation: slide-up 0.25s ease-out; +} +@keyframes slide-up { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} + +.pb-safe { + padding-bottom: max(1.25rem, env(safe-area-inset-bottom)); +} + +.animate-fade-in-up { + animation: fade-in-up 0.25s ease-out; +} +@keyframes fade-in-up { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +}