cc-haha/desktop/src/lib/touchH5.ts
程序员阿江(Relakkes) 6818db34fb fix(desktop): overhaul touch-H5 interaction (#780)
Mark <html data-touch-h5> before first paint when the bundle runs in a
phone browser (no Electron host + coarse pointer) and scope every
mobile-only fix under it, so desktop shells and desktop browsers are
untouched:

- raise form controls to 16px and cap the iOS viewport scale, so
  focusing the composer no longer zooms the page and never zooms back
- disable content-visibility paint skipping on transcript/trace rows
  there (long-press selection on iOS WebKit jumps or drops selections
  when they extend into skipped rows); halve the virtualization
  thresholds on touch as the replacement paint bound for long sessions
- size the app shell to visualViewport so the composer rides the soft
  keyboard instead of being covered, snap back WebKit's keyboard pan,
  and keep the transcript tail pinned while the container shrinks
- pad the shell with safe-area insets (viewport-fit=cover) and drop the
  bottom inset while the keyboard is up
- keep message action bars (copy/branch) always visible on touch since
  hover never fires there; kill the WKWebView tap flash and body
  rubber-banding
- move the two inline content-visibility styles (trace message blocks,
  trace list rows) to classes so the touch scope can reach them

Tested: cd desktop && npx vitest run (1474 tests)
Tested: cd desktop && npx tsc --noEmit && npx vite build
Tested: Playwright chromium smoke against the built dist - desktop
context unchanged, iPhone/WeChat and Android contexts get the marker,
viewport lock, 16px controls, visible action bars and selectable rows
2026-06-13 09:16:37 +08:00

183 lines
6.6 KiB
TypeScript

/**
* Touch-H5 runtime marker.
*
* The same bundle serves three runtimes: the Electron desktop shell, desktop
* browsers, and phone browsers reaching the H5 server (WeChat scan, Safari,
* etc.). Phone WebKit needs a handful of behavior fixes (focus auto-zoom,
* text-selection vs content-visibility, rubber-band scrolling) that must NOT
* leak into the desktop runtimes, so instead of scattering UA checks through
* components we mark `<html data-touch-h5>` once before first paint and scope
* every mobile-only CSS rule under that attribute (see globals.css).
*
* Runs synchronously at module-load time in main.tsx — before React mounts —
* so it must stay dependency-free (no api client, no stores).
*/
export const TOUCH_H5_ATTRIBUTE = 'data-touch-h5'
export const TOUCH_H5_KEYBOARD_ATTRIBUTE = 'data-touch-h5-keyboard'
export const TOUCH_H5_VIEWPORT_HEIGHT_VAR = '--touch-h5-viewport-height'
/** True when initializeTouchH5 marked this document as a touch-H5 runtime. */
export function isTouchH5Document(
doc: Document | undefined = typeof document === 'undefined' ? undefined : document,
): boolean {
return !!doc?.documentElement.hasAttribute(TOUCH_H5_ATTRIBUTE)
}
export type TouchH5Environment = {
/** Electron preload injects `window.desktopHost`; its absence means browser. */
hasDesktopHost: boolean
/** Primary pointer is coarse (touch) — phones/tablets, not touch laptops. */
coarsePointer: boolean
maxTouchPoints: number
userAgent: string
platform: string
}
type WindowLike = Window & typeof globalThis
export function detectTouchH5Environment(win: WindowLike = window): TouchH5Environment {
const nav = win.navigator
let coarsePointer = false
try {
coarsePointer = typeof win.matchMedia === 'function' && win.matchMedia('(pointer: coarse)').matches
} catch {
coarsePointer = false
}
return {
hasDesktopHost: !!win.desktopHost,
coarsePointer,
maxTouchPoints: typeof nav?.maxTouchPoints === 'number' ? nav.maxTouchPoints : 0,
userAgent: nav?.userAgent ?? '',
platform: nav?.platform ?? '',
}
}
export function isTouchH5Environment(env: TouchH5Environment): boolean {
if (env.hasDesktopHost) return false
return env.coarsePointer || env.maxTouchPoints > 0
}
/** iPadOS 13+ masquerades as macOS; the touch-point count gives it away. */
export function isIOSEnvironment(env: TouchH5Environment): boolean {
if (/iPad|iPhone|iPod/.test(env.userAgent)) return true
return env.platform === 'MacIntel' && env.maxTouchPoints > 1
}
const IOS_VIEWPORT_CONTENT =
'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover'
/**
* iOS WKWebView (Safari and the WeChat in-app browser) zooms the whole page
* when a focused form control renders below 16px, and never zooms back out.
* Raising control font sizes (globals.css) fixes the trigger; capping the
* viewport scale also stops double-tap/pinch zoom from leaving the chat shell
* in a half-zoomed state. Applied only on iOS — Android has no focus-zoom
* behavior, so it keeps pinch-zoom accessibility.
*/
function lockIOSViewport(doc: Document) {
const viewport = doc.querySelector('meta[name="viewport"]')
if (viewport) {
viewport.setAttribute('content', IOS_VIEWPORT_CONTENT)
return
}
const meta = doc.createElement('meta')
meta.setAttribute('name', 'viewport')
meta.setAttribute('content', IOS_VIEWPORT_CONTENT)
doc.head.appendChild(meta)
}
/**
* iOS WKWebView (notoriously the WeChat one) leaves the page scrolled up
* after the soft keyboard collapses, so the fixed app shell sits half off
* screen. The body never legitimately scrolls (the shell is 100dvh with
* inner scroll areas), so snapping back to 0 after an input blurs is safe.
*/
function installIOSKeyboardCollapseFix(win: WindowLike) {
win.addEventListener('focusout', (event) => {
const target = event.target
if (!(target instanceof win.HTMLElement)) return
if (!/^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName) && !target.isContentEditable) return
win.requestAnimationFrame(() => {
const active = win.document.activeElement
const refocusedInput =
active instanceof win.HTMLElement &&
(/^(INPUT|TEXTAREA|SELECT)$/.test(active.tagName) || active.isContentEditable)
if (refocusedInput) return
if (win.scrollX !== 0 || win.scrollY !== 0) {
win.scrollTo(0, 0)
}
})
})
}
/**
* Keep the app shell sized to the *visual* viewport.
*
* 100dvh tracks browser chrome but NOT the soft keyboard: when the keyboard
* opens, iOS WebKit leaves the layout viewport tall and pans it upward so the
* focused field is visible — pushing the header (and part of the transcript)
* off screen. Publishing visualViewport.height as a CSS variable lets the
* shell shrink to the visible area instead (globals.css), so the composer sits
* right above the keyboard with the transcript still readable. The pan is then
* redundant and gets snapped back. On browsers that already resize layout for
* the keyboard (Android Chrome default) the variable simply equals 100dvh.
*/
const KEYBOARD_VISIBLE_MIN_GAP_PX = 80
function installVisualViewportFit(win: WindowLike) {
const viewport = win.visualViewport
if (!viewport) return
const root = win.document.documentElement
const sync = () => {
const height = viewport.height
if (!Number.isFinite(height) || height <= 0) return
root.style.setProperty(TOUCH_H5_VIEWPORT_HEIGHT_VAR, `${Math.round(height)}px`)
const keyboardVisible = win.innerHeight - height > KEYBOARD_VISIBLE_MIN_GAP_PX
root.toggleAttribute(TOUCH_H5_KEYBOARD_ATTRIBUTE, keyboardVisible)
if (keyboardVisible && (win.scrollX !== 0 || win.scrollY !== 0)) {
win.scrollTo(0, 0)
}
}
viewport.addEventListener('resize', sync)
viewport.addEventListener('scroll', sync)
sync()
}
const windowListenersInstalled = new WeakSet<object>()
/**
* Mark the document for touch-H5 styling and apply iOS-specific fixes.
* No-op (and returns false) in the Electron shell and desktop browsers.
*/
export function initializeTouchH5(win: WindowLike | undefined = typeof window === 'undefined' ? undefined : window): boolean {
if (!win) return false
const env = detectTouchH5Environment(win)
if (!isTouchH5Environment(env)) return false
win.document.documentElement.setAttribute(TOUCH_H5_ATTRIBUTE, 'true')
const ios = isIOSEnvironment(env)
if (ios) {
lockIOSViewport(win.document)
}
if (!windowListenersInstalled.has(win)) {
windowListenersInstalled.add(win)
installVisualViewportFit(win)
if (ios) {
installIOSKeyboardCollapseFix(win)
}
}
return true
}