diff --git a/extensions/react-widget/src/components/DocsGPTWidget.tsx b/extensions/react-widget/src/components/DocsGPTWidget.tsx index 58aee967..e320397f 100644 --- a/extensions/react-widget/src/components/DocsGPTWidget.tsx +++ b/extensions/react-widget/src/components/DocsGPTWidget.tsx @@ -24,7 +24,10 @@ import { normalizeExtensions, useAttachments, } from '../hooks/useAttachments'; +import { useBackDismiss } from '../hooks/useBackDismiss'; import { useDictation } from '../hooks/useDictation'; +import { useVisualViewportBounds } from '../hooks/useVisualViewportBounds'; +import { isTouchPrimary } from '../utils/helper'; import { AttachButton, AttachmentChips, @@ -304,7 +307,8 @@ const WidgetContainer = styled.div<{ $modal?: boolean }>` @media only screen and (max-width: 768px) { right: 0; - bottom: 0; + /* Keyboard inset; see useVisualViewportBounds. */ + bottom: var(--dgpt-vv-bottom, 0px); &.modal { transform: none; } @@ -325,6 +329,9 @@ const StyledContainer = styled.div<{ $isOpen: boolean }>` background-color: ${(props) => props.theme.primary.bg}; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + /* all: initial re-enables Safari's text auto-inflation. */ + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; display: flex; padding: 0; overflow: hidden; @@ -368,10 +375,13 @@ const StyledContainer = styled.div<{ $isOpen: boolean }>` @media only screen and (max-width: 768px) { width: 100vw; - height: 100dvh; + /* 100dvh unless a keyboard is covering part of it. */ + height: var(--dgpt-vv-height, 100dvh); max-width: 100vw; - max-height: 100dvh; + max-height: var(--dgpt-vv-height, 100dvh); border-radius: 0; + /* Expanding is desktop-only; easing after the keyboard reads as lag. */ + transition: none; } `; @@ -847,7 +857,7 @@ const StatusLine = styled.div` min-width: 0; max-width: 100%; font-size: 12px; - font-family: sans-serif; + font-family: inherit; color: ${(props) => props.theme.secondary.text}; `; const StatusDot = styled.span` @@ -892,7 +902,7 @@ const Thought = styled.div` padding-left: 10px; border-left: 2px solid ${(props) => props.theme.hairline}; font-size: 12px; - font-family: sans-serif; + font-family: inherit; font-style: italic; line-height: 1.5; white-space: pre-wrap; @@ -1034,6 +1044,11 @@ const StyledTextarea = styled.textarea<{ $hidden?: boolean }>` text-align: left; color: ${(props) => props.theme.secondary.text}; } + + /* iOS Safari zooms the page in on any field under 16px. */ + @media (pointer: coarse) { + font-size: 16px; + } `; const StyledButton = styled.button` display: flex; @@ -1323,6 +1338,7 @@ export const WidgetCore = ({ const [isDraggingFiles, setIsDraggingFiles] = React.useState(false); const abortRef = useRef(null); + const containerRef = useRef(null); const conversationRef = useRef(null); const endMessageRef = React.useRef(null); const promptRef = React.useRef(null); @@ -1330,6 +1346,20 @@ export const WidgetCore = ({ // dragenter/dragleave fire per child crossed, hence a depth count. const dragDepthRef = React.useRef(0); + // On a phone the composer waits to be tapped: autofocus costs half the + // display to a keyboard nobody asked for. + const [touchPrimary] = React.useState(isTouchPrimary); + + // Typed as a click handler, but no close path reads the event. + const closePanel = () => { + (handleClose as ((event?: React.MouseEvent) => void) | undefined)?.(); + }; + + // The node exists only once mounted. + useVisualViewportBounds(isOpen && mounted, containerRef); + // Touch only: nothing fills the screen on desktop. + useBackDismiss(isOpen && touchPrimary, closePanel); + // An empty list disables attachments. const acceptedExtensions = React.useMemo( () => normalizeExtensions(allowedFileExtensions), @@ -1440,6 +1470,20 @@ export const WidgetCore = ({ else scrollToLatest(); }, [queries.length, queries[queries.length - 1]?.response, status]); + // Shortening the panel leaves scrollTop where it was, dropping the tail of + // the latest answer below the fold. A frame later, once the height lands. + React.useEffect(() => { + const viewport = window.visualViewport; + if (!isOpen || !viewport) return; + const repin = () => + window.requestAnimationFrame(() => { + const el = conversationRef.current; + if (el && isPinnedToLatest) el.scrollTop = el.scrollHeight; + }); + viewport.addEventListener('resize', repin); + return () => viewport.removeEventListener('resize', repin); + }, [isOpen, isPinnedToLatest]); + const setFeedbackAt = (index: number, value?: FEEDBACK) => setQueries((prev: Query[]) => prev.map((q, i) => { @@ -1847,6 +1891,7 @@ export const WidgetCore = ({ {isOpen && size === 'large' && } { @@ -2114,7 +2159,7 @@ export const WidgetCore = ({ $hidden={isDictating} id="chatInput" ref={promptRef} - autoFocus + autoFocus={!touchPrimary} onInput={handleUserInput} value={prompt} onChange={handlePromptChange} diff --git a/extensions/react-widget/src/components/SearchBar.tsx b/extensions/react-widget/src/components/SearchBar.tsx index f8f75b27..e2816401 100644 --- a/extensions/react-widget/src/components/SearchBar.tsx +++ b/extensions/react-widget/src/components/SearchBar.tsx @@ -4,11 +4,13 @@ import { WidgetCore } from './DocsGPTWidget'; import { DEFAULT_AVATAR } from './defaultAvatar'; import { radii, themes } from './tokens'; import { MicButton, VoiceWaveform } from './ComposerControls'; +import { useBackDismiss } from '../hooks/useBackDismiss'; import { useDictation } from '../hooks/useDictation'; +import { useVisualViewportBounds } from '../hooks/useVisualViewportBounds'; import { SearchBarProps } from '@/types'; import { getSearchResults } from '../requests/searchAPI'; import { Result } from '@/types'; -import { getOS, processMarkdownString } from '../utils/helper'; +import { getOS, isTouchPrimary, processMarkdownString } from '../utils/helper'; import DOMPurify from 'dompurify'; import { CodeIcon, @@ -29,6 +31,9 @@ const Main = styled.div` all: initial; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + /* all: initial re-enables Safari's text auto-inflation. */ + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; `; const SearchButton = styled.button<{ $inputWidth: string }>` @@ -78,7 +83,8 @@ const SearchOverlay = styled.div` const SearchResults = styled.div` position: fixed; - top: 50%; + /* Centred on the strip left above an open keyboard, else the viewport. */ + top: calc(var(--dgpt-vv-top, 0px) + var(--dgpt-vv-height, 100%) / 2); left: 50%; z-index: 100; transform: translate(-50%, -50%); @@ -101,6 +107,9 @@ const SearchResults = styled.div` @media only screen and (max-width: 768px) { width: 90vw; height: 80vh; + /* Into the strip above an open keyboard, less a gutter at each end. With + no keyboard the fallback resolves against the viewport and never bites. */ + max-height: calc(var(--dgpt-vv-height, 100%) - 32px); } `; @@ -336,6 +345,7 @@ export const SearchBar = ({ const [isWidgetOpen, setIsWidgetOpen] = React.useState(false); const inputRef = React.useRef(null); const containerRef = React.useRef(null); + const resultsRef = React.useRef(null); const [isResultVisible, setIsResultVisible] = React.useState(false); const [results, setResults] = React.useState([]); const debounceTimeout = React.useRef | null>( @@ -358,7 +368,13 @@ export const SearchBar = ({ onEnd: focusSearchInput, }); const browserOS = getOS(); - const isTouch = 'ontouchstart' in window; + // A touchscreen laptop still has Ctrl+K; this asks if touch is the only + // way in. + const [isTouch] = React.useState(isTouchPrimary); + + useVisualViewportBounds(isResultVisible, resultsRef); + // Touch only: nothing fills the screen on desktop. + useBackDismiss(isResultVisible && isTouch, () => setIsResultVisible(false)); const getKeyboardInstruction = () => { if (isResultVisible) return 'Enter'; @@ -463,7 +479,7 @@ export const SearchBar = ({ {isResultVisible && ( <> setIsResultVisible(false)} /> - + {dictation.isDictating && ( handleKeyDown(e)} placeholder={placeholder} - autoFocus + /* A keyboard would cover the results. */ + autoFocus={!isTouch} /> {dictation.available && ( void> = []; +let entryPushed = false; +let releaseTimer: ReturnType | 0 = 0; +let releasing = false; + +const ours = () => + (window.history.state as Record | null)?.[HISTORY_MARKER] === + true; + +const arm = () => { + if (entryPushed) return; + entryPushed = true; + if (!ours()) window.history.pushState({ [HISTORY_MARKER]: true }, ''); + window.addEventListener('popstate', handlePopState); +}; + +const handlePopState = () => { + if (releasing) { + releasing = false; + entryPushed = ours(); + if (layers.length > 0) arm(); + else if (!entryPushed) + window.removeEventListener('popstate', handlePopState); + return; + } + // A host entry stacked above ours pops first, leaving ours in place. + entryPushed = ours(); + const remaining = layers.length > 1; + layers[layers.length - 1]?.(); + if (remaining) arm(); +}; + +const register = (dismiss: () => void) => { + layers.push(dismiss); + if (releaseTimer) { + clearTimeout(releaseTimer); + releaseTimer = 0; + } + arm(); +}; + +const unregister = (dismiss: () => void) => { + const index = layers.lastIndexOf(dismiss); + if (index !== -1) layers.splice(index, 1); + if (layers.length > 0 || releaseTimer) return; + // The palette hands over to the chat panel in one commit, every cleanup + // before any setup, so release only once nothing reclaims it a task later. + releaseTimer = setTimeout(() => { + releaseTimer = 0; + if (layers.length > 0) return; + entryPushed = false; + // Closing by button or overlay would otherwise leave the entry behind. + if (ours()) { + releasing = true; + window.history.back(); + return; + } + window.removeEventListener('popstate', handlePopState); + }); +}; + +/** + * Closes an open layer on the hardware back button instead of leaving the page. + * All layers share one history entry, and `onDismiss` is read through a ref. + */ +export function useBackDismiss(active: boolean, onDismiss: () => void): void { + const dismissRef = React.useRef(onDismiss); + + React.useEffect(() => { + dismissRef.current = onDismiss; + }); + + React.useEffect(() => { + if (!active || typeof window === 'undefined' || !window.history) return; + const dismiss = () => dismissRef.current(); + register(dismiss); + return () => unregister(dismiss); + }, [active]); +} diff --git a/extensions/react-widget/src/hooks/useVisualViewportBounds.ts b/extensions/react-widget/src/hooks/useVisualViewportBounds.ts new file mode 100644 index 00000000..004405b3 --- /dev/null +++ b/extensions/react-widget/src/hooks/useVisualViewportBounds.ts @@ -0,0 +1,76 @@ +import React from 'react'; + +/** Below this the gap is the browser's own chrome, not a keyboard. */ +const KEYBOARD_MIN_INSET_PX = 120; + +/** + * Publishes the visual viewport as `--dgpt-vv-top`, `--dgpt-vv-bottom` and + * `--dgpt-vv-height` on the node while a keyboard covers it. Neither + * `position: fixed` nor `100dvh` notices one, so a full-screen panel keeps its + * height and slides its header off the display, close button and all. Written + * straight to the node, since these events fire every frame while the keyboard + * animates. + */ +export function useVisualViewportBounds( + active: boolean, + ref: React.RefObject, +): void { + React.useEffect(() => { + const viewport = + typeof window === 'undefined' ? null : window.visualViewport; + const node = ref.current; + if (!active || !viewport || !node) return; + + let frame = 0; + let written = ''; + + const clear = () => { + if (!written) return; + written = ''; + node.style.removeProperty('--dgpt-vv-top'); + node.style.removeProperty('--dgpt-vv-height'); + node.style.removeProperty('--dgpt-vv-bottom'); + }; + + const apply = () => { + frame = 0; + // Pinch-zoom shrinks the viewport the same way but covers nothing. + if (viewport.scale > 1.01) { + clear(); + return; + } + // A quirks-mode host reports its content height from `clientHeight`. + const layoutHeight = Math.min( + document.documentElement.clientHeight, + window.innerHeight, + ); + const top = viewport.offsetTop; + const bottom = Math.max(0, layoutHeight - top - viewport.height); + if (top + bottom < KEYBOARD_MIN_INSET_PX) { + clear(); + return; + } + const next = `${top}|${viewport.height}|${bottom}`; + if (next === written) return; + written = next; + node.style.setProperty('--dgpt-vv-top', `${top}px`); + node.style.setProperty('--dgpt-vv-height', `${viewport.height}px`); + node.style.setProperty('--dgpt-vv-bottom', `${bottom}px`); + }; + + const schedule = () => { + if (!frame) frame = window.requestAnimationFrame(apply); + }; + + apply(); + viewport.addEventListener('resize', schedule); + viewport.addEventListener('scroll', schedule); + + return () => { + if (frame) window.cancelAnimationFrame(frame); + viewport.removeEventListener('resize', schedule); + viewport.removeEventListener('scroll', schedule); + clear(); + }; + }, [active, ref]); +} diff --git a/extensions/react-widget/src/utils/helper.ts b/extensions/react-widget/src/utils/helper.ts index 75c8c2a0..adb3094b 100644 --- a/extensions/react-widget/src/utils/helper.ts +++ b/extensions/react-widget/src/utils/helper.ts @@ -1,3 +1,12 @@ +/** + * Whether touch is the only pointer: a phone or tablet, not a laptop with a + * touchscreen, which `'ontouchstart' in window` cannot tell apart. + */ +export const isTouchPrimary = (): boolean => { + if (typeof window === 'undefined' || !window.matchMedia) return false; + return window.matchMedia('(hover: none) and (pointer: coarse)').matches; +}; + export const getOS = () => { const platform = window.navigator.platform; const userAgent = window.navigator.userAgent || window.navigator.vendor;