From 059473f81d7bab60e33ad83ee83f808c2e108de6 Mon Sep 17 00:00:00 2001 From: avelli Date: Tue, 12 May 2026 19:36:58 +0800 Subject: [PATCH 1/8] =?UTF-8?q?refactor:=20=E6=8F=90=E5=8F=96=20useHintToo?= =?UTF-8?q?ltip=20hook=20=E7=BB=9F=E4=B8=80=20tooltip=20=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 InputBar 中 5 组重复的 hint 状态(compression、moderation、size、 quality、nLimit)及其对应的 timer ref、show/hide/clear/startTouch 函数 替换为统一的 useHintTooltip hook 调用,减少约 90 行样板代码。 Co-Authored-By: Claude Opus 4.6 --- src/components/InputBar.tsx | 221 +++++++----------------------------- src/hooks/useHintTooltip.ts | 54 +++++++++ 2 files changed, 98 insertions(+), 177 deletions(-) create mode 100644 src/hooks/useHintTooltip.ts diff --git a/src/components/InputBar.tsx b/src/components/InputBar.tsx index c12acf2d..3678558a 100644 --- a/src/components/InputBar.tsx +++ b/src/components/InputBar.tsx @@ -12,6 +12,7 @@ import { getSafeBoundingClientRect } from '../lib/domRect' import Select from './Select' import SizePickerModal from './SizePickerModal' import ViewportTooltip from './ViewportTooltip' +import { useHintTooltip } from '../hooks/useHintTooltip' function getMentionTagTextLength(el: Element) { @@ -409,10 +410,6 @@ 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) @@ -435,18 +432,12 @@ 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() @@ -488,6 +479,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 +564,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 +608,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 +633,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) { @@ -1463,12 +1330,12 @@ export default function InputBar() {
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); } +} From 79334b3b6c708b049696d0ce4f1afbf7b73528e1 Mon Sep 17 00:00:00 2001 From: avelli Date: Wed, 13 May 2026 11:18:11 +0800 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20=E7=A7=BB=E5=8A=A8=E7=AB=AF?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=9B=B8=E5=86=8C=E5=85=A5=E5=8F=A3=E6=8C=89?= =?UTF-8?q?=E9=92=AE=EF=BC=8C=E9=94=AE=E7=9B=98=E5=BC=B9=E5=87=BA=E6=97=B6?= =?UTF-8?q?=E9=9A=90=E8=97=8F=E9=99=84=E4=BB=B6=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在相机按钮前新增 + 号按钮,点击打开相册(fileInputRef) - 用 keyboardVisible 条件包裹两个按钮,键盘弹出时自动隐藏 Co-Authored-By: Claude Opus 4.6 --- src/components/InputBar.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/components/InputBar.tsx b/src/components/InputBar.tsx index 7d5e2020..e58e235a 100644 --- a/src/components/InputBar.tsx +++ b/src/components/InputBar.tsx @@ -1830,6 +1830,22 @@ export default function InputBar() { )}
+ {!keyboardVisible &&
+ +
}
Date: Wed, 13 May 2026 10:52:46 +0800 Subject: [PATCH 6/8] =?UTF-8?q?style:=20=E6=94=B6=E7=B4=A7=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E7=AB=AF=E5=8F=82=E8=80=83=E5=9B=BE=E6=A0=8F=E9=97=B4?= =?UTF-8?q?=E8=B7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- src/components/InputBar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/InputBar.tsx b/src/components/InputBar.tsx index e58e235a..3e670498 100644 --- a/src/components/InputBar.tsx +++ b/src/components/InputBar.tsx @@ -1396,7 +1396,7 @@ export default function InputBar() { const renderImageThumbs = () => { return (
-
+
{inputImages.map((img, idx) => renderImageThumb(img, idx))} {renderClearAllButton()}
From 46c4d4d10cb10a175863671008848c77621f8418 Mon Sep 17 00:00:00 2001 From: avelli Date: Wed, 13 May 2026 10:58:54 +0800 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=94=AE=E7=9B=98?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=E5=9F=BA=E5=87=86=E6=BC=82=E7=A7=BB=E5=8F=8A?= =?UTF-8?q?=E8=B7=A8=E6=96=AD=E7=82=B9=E5=88=87=E6=8D=A2=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E4=B8=A2=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useKeyboardVisible 改用 screen.height 作为稳定基准,避免 PWA 全屏切换时判断失准 - prompt 同步 effect 加入 isMobile 依赖,确保跨 640px 断点切换时内容正确恢复 Co-Authored-By: Claude Opus 4.6 --- src/components/InputBar.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/InputBar.tsx b/src/components/InputBar.tsx index 3e670498..3bd43bc9 100644 --- a/src/components/InputBar.tsx +++ b/src/components/InputBar.tsx @@ -282,24 +282,24 @@ function useKeyboardVisible() { useEffect(() => { let fromViewport = false let fromFocus = false - const initialHeight = window.innerHeight + 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 < window.innerHeight * KEYBOARD_THRESHOLD + fromViewport = vv.height < baseHeight * KEYBOARD_THRESHOLD update() } } const onWindowResize = () => { const curr = window.innerHeight - if (curr > initialHeight * 0.85) { + if (curr > baseHeight * 0.85) { fromFocus = false update() - } else if (curr < initialHeight * KEYBOARD_THRESHOLD) { + } else if (curr < baseHeight * KEYBOARD_THRESHOLD) { fromFocus = true update() } @@ -1007,7 +1007,7 @@ export default function InputBar() { if (el.innerHTML !== html) { el.innerHTML = html } - }, [prompt, inputImages]) + }, [prompt, inputImages, isMobile]) useEffect(() => { adjustTextareaHeight() From 43aee4e12c5034315ff0a3fc3a6152cf8a093bc4 Mon Sep 17 00:00:00 2001 From: avelli Date: Wed, 13 May 2026 11:55:57 +0800 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=B7=A8=E6=96=AD?= =?UTF-8?q?=E7=82=B9=E5=88=87=E6=8D=A2=E6=97=B6=E8=BE=93=E5=85=A5=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E6=B3=84=E6=BC=8F=E5=88=B0=E6=8C=89=E9=92=AE=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为移动端/桌面端输入区域添加 Fragment key,强制 React 卸载重建而非就地协调 - isMobile 变化时重置 prevHeightRef,新元素跳过动画直接设置正确高度 Co-Authored-By: Claude Opus 4.6 --- src/components/InputBar.tsx | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/components/InputBar.tsx b/src/components/InputBar.tsx index 3bd43bc9..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' @@ -975,11 +975,19 @@ export default function InputBar() { 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' @@ -987,6 +995,10 @@ export default function InputBar() { prevHeightRef.current = targetH }, [isMobile]) + useEffect(() => { + prevHeightRef.current = 0 + }, [isMobile]) + // 将 prompt 同步渲染到 contentEditable(含胶囊 tag) useEffect(() => { const el = textareaRef.current @@ -1802,7 +1814,7 @@ export default function InputBar() { {/* 输入框 + 按钮 */} {isMobile ? ( - <> + {!keyboardVisible &&
{renderMobileParamChips()}
}
{showAtImageMenu && ( @@ -1928,9 +1940,9 @@ export default function InputBar() {
- + ) : ( - <> + {/* 桌面端输入框 */}
{showAtImageMenu && ( @@ -2088,7 +2100,7 @@ export default function InputBar() {
- + )}