mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 18:15:02 +00:00
(fix:widget/ui): mobile keyboard, back button
This commit is contained in:
1 parent
8a9f3a75d6
commit
d40d344bd6
5 files changed
+224
-11
No files matched your search
@@ -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<AbortController | null>(null);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const conversationRef = useRef<HTMLDivElement | null>(null);
|
||||
const endMessageRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const promptRef = React.useRef<HTMLTextAreaElement | null>(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' && <Overlay onClick={handleClose} />}
|
||||
{
|
||||
<WidgetContainer
|
||||
ref={containerRef}
|
||||
className={`${size !== 'large' ? (isOpen ? 'open' : 'close') : 'modal'}`}
|
||||
$modal={size === 'large'}
|
||||
>
|
||||
@@ -2114,7 +2159,7 @@ export const WidgetCore = ({
|
||||
$hidden={isDictating}
|
||||
id="chatInput"
|
||||
ref={promptRef}
|
||||
autoFocus
|
||||
autoFocus={!touchPrimary}
|
||||
onInput={handleUserInput}
|
||||
value={prompt}
|
||||
onChange={handlePromptChange}
|
||||
|
||||
@@ -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<boolean>(false);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const containerRef = React.useRef<HTMLInputElement>(null);
|
||||
const resultsRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [isResultVisible, setIsResultVisible] = React.useState<boolean>(false);
|
||||
const [results, setResults] = React.useState<Result[]>([]);
|
||||
const debounceTimeout = React.useRef<ReturnType<typeof setTimeout> | 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 && (
|
||||
<>
|
||||
<SearchOverlay onClick={() => setIsResultVisible(false)} />
|
||||
<SearchResults>
|
||||
<SearchResults ref={resultsRef}>
|
||||
<SearchHeader>
|
||||
{dictation.isDictating && (
|
||||
<VoiceWaveform
|
||||
@@ -486,7 +502,8 @@ export const SearchBar = ({
|
||||
}}
|
||||
onKeyDown={(e) => handleKeyDown(e)}
|
||||
placeholder={placeholder}
|
||||
autoFocus
|
||||
/* A keyboard would cover the results. */
|
||||
autoFocus={!isTouch}
|
||||
/>
|
||||
{dictation.available && (
|
||||
<MicButton
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
|
||||
/** Marks our own history entry, so only an entry we pushed is popped. */
|
||||
const HISTORY_MARKER = 'dgptOverlay';
|
||||
|
||||
const layers: Array<() => void> = [];
|
||||
let entryPushed = false;
|
||||
let releaseTimer: ReturnType<typeof setTimeout> | 0 = 0;
|
||||
|
||||
const ours = () =>
|
||||
(window.history.state as Record<string, unknown> | null)?.[HISTORY_MARKER] ===
|
||||
true;
|
||||
|
||||
const handlePopState = () => {
|
||||
// A host entry stacked above ours pops first, leaving ours in place.
|
||||
entryPushed = ours();
|
||||
layers[layers.length - 1]?.();
|
||||
};
|
||||
|
||||
const register = (dismiss: () => void) => {
|
||||
layers.push(dismiss);
|
||||
if (releaseTimer) {
|
||||
clearTimeout(releaseTimer);
|
||||
releaseTimer = 0;
|
||||
}
|
||||
if (entryPushed) return;
|
||||
entryPushed = true;
|
||||
window.history.pushState({ [HISTORY_MARKER]: true }, '');
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
};
|
||||
|
||||
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;
|
||||
window.removeEventListener('popstate', handlePopState);
|
||||
if (!entryPushed) return;
|
||||
entryPushed = false;
|
||||
// Closing by button or overlay would otherwise leave the entry behind.
|
||||
if (ours()) window.history.back();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 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]);
|
||||
}
|
||||
@@ -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<HTMLElement | null>,
|
||||
): 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]);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user