screenSize.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549
  1. /**
  2. * 屏幕尺寸检测工具
  3. * 用于识别小屏幕设备并提供响应式布局参数
  4. *
  5. * V2.0: 完全计算式布局系统
  6. * - 从可用高度出发,逆向计算所有元素位置
  7. * - 确保无重叠、无溢出
  8. * - 自动降级(滚动)
  9. */
  10. export interface ScreenSize {
  11. width: number;
  12. height: number;
  13. isSmallScreen: boolean;
  14. isVerySmallScreen: boolean;
  15. isTooSmall: boolean; // 极小屏幕,需要滚动条
  16. availableHeight: number;
  17. }
  18. /**
  19. * 动态检测浏览器底部安全区域
  20. * 用于适配移动端浏览器的系统 UI(工具栏、Home Indicator 等)
  21. *
  22. * @returns 底部安全区域的像素值(px)
  23. */
  24. export function detectBottomSafeArea(): number {
  25. // 方法 1: 尝试读取 CSS env(safe-area-inset-bottom)
  26. // 这是 iOS Safari 和部分 Android 浏览器支持的标准方法
  27. const testDiv = document.createElement('div');
  28. testDiv.style.cssText = `
  29. position: fixed;
  30. bottom: 0;
  31. left: 0;
  32. width: 1px;
  33. height: 1px;
  34. padding-bottom: env(safe-area-inset-bottom);
  35. pointer-events: none;
  36. visibility: hidden;
  37. `;
  38. document.body.appendChild(testDiv);
  39. const computedStyle = window.getComputedStyle(testDiv);
  40. const paddingBottom = parseFloat(computedStyle.paddingBottom);
  41. document.body.removeChild(testDiv);
  42. // 如果成功读取到安全区域值(> 0)
  43. if (paddingBottom > 0) {
  44. return paddingBottom;
  45. }
  46. // 方法 2: 使用 visualViewport API 检测实际可视区域
  47. // 某些浏览器会因为底部工具栏而缩小可视区域
  48. if (window.visualViewport) {
  49. const viewportHeight = window.visualViewport.height;
  50. const windowHeight = window.innerHeight;
  51. const difference = windowHeight - viewportHeight;
  52. // 如果差异 > 20px,认为有底部 UI 遮挡
  53. if (difference > 20) {
  54. return difference;
  55. }
  56. }
  57. // 方法 3: 根据 User Agent 提供默认值
  58. const ua = navigator.userAgent.toLowerCase();
  59. // iOS 设备(有 Home Indicator 或 Home 按钮)
  60. if (/iphone|ipad|ipod/.test(ua)) {
  61. // iPhone X 及以后的全面屏机型
  62. if (window.screen.height >= 812) {
  63. return 34; // Home Indicator 高度
  64. }
  65. // 老款 iPhone(有 Home 按钮)
  66. return 0;
  67. }
  68. // Android 设备
  69. if (/android/.test(ua)) {
  70. return 48; // Android 底部导航栏典型高度
  71. }
  72. // 桌面浏览器
  73. return 0;
  74. }
  75. /**
  76. * 获取当前屏幕尺寸信息
  77. */
  78. export const getScreenSize = (): ScreenSize => {
  79. const width = window.innerWidth;
  80. const height = window.innerHeight;
  81. // 小屏幕:高度 < 700px (iPhone SE, iPhone 8 等)
  82. const isSmallScreen = height < 700;
  83. // 超小屏幕:高度 < 650px (iPhone SE 第一代等)
  84. const isVerySmallScreen = height < 650;
  85. // 极小屏幕:宽度 < 280px 或 高度 < 500px
  86. // 此时即使最小缩放也无法完整显示,需要启用滚动条
  87. const isTooSmall = width < 280 || height < 500;
  88. // 可用高度(扣除浏览器UI)
  89. const availableHeight = height;
  90. return {
  91. width,
  92. height,
  93. isSmallScreen,
  94. isVerySmallScreen,
  95. isTooSmall,
  96. availableHeight,
  97. };
  98. };
  99. /**
  100. * 根据屏幕高度计算缩放比例
  101. * @param baseHeight 基准高度(设计稿高度,如 852px)
  102. * @returns 缩放比例 (0.7 - 1.0)
  103. */
  104. export const getScaleFactor = (baseHeight: number = 852): number => {
  105. const { height } = getScreenSize();
  106. // 计算缩放比例,最小 0.7,最大 1.0
  107. const scale = Math.max(0.7, Math.min(1.0, height / baseHeight));
  108. return scale;
  109. };
  110. /**
  111. * 根据屏幕高度获取布局参数
  112. */
  113. export interface LayoutParams {
  114. // 头像区域
  115. avatarScale: number;
  116. avatarTopOffset: number;
  117. // 情感标签
  118. emotionTopOffset: number;
  119. // 录音按钮
  120. recordButtonTopOffset: number;
  121. recordButtonSize: number;
  122. // 声纹波形
  123. waveformHeight: number;
  124. // 间距
  125. verticalGap: number;
  126. buttonGap: number; // 取消和发送按钮之间的水平间距
  127. // 进度环边距(进度环比录音按钮大的尺寸)
  128. progressRingPadding: number;
  129. }
  130. export const getLayoutParams = (): LayoutParams => {
  131. const { isSmallScreen, isVerySmallScreen, isTooSmall } = getScreenSize();
  132. if (isTooSmall) {
  133. // 极小屏幕 (< 280px 宽或 < 500px 高):最小可用尺寸
  134. // 此时会启用滚动条,但保证元素可点击
  135. return {
  136. avatarScale: 0.6,
  137. avatarTopOffset: 60,
  138. emotionTopOffset: 320,
  139. recordButtonTopOffset: 380,
  140. recordButtonSize: 72, // 最小可点击尺寸
  141. waveformHeight: 44,
  142. verticalGap: 10,
  143. buttonGap: 35, // 固定值,不缩放
  144. progressRingPadding: 14, // 72 * 0.75 * 0.26 ≈ 14 (按比例缩放)
  145. };
  146. } else if (isVerySmallScreen) {
  147. // 超小屏幕 (< 650px):大幅缩小
  148. return {
  149. avatarScale: 0.7,
  150. avatarTopOffset: 80,
  151. emotionTopOffset: 380,
  152. recordButtonTopOffset: 450,
  153. recordButtonSize: 80,
  154. waveformHeight: 48,
  155. verticalGap: 12,
  156. buttonGap: 35, // 固定值,不缩放
  157. progressRingPadding: 15, // 80 * 0.83 * 0.23 ≈ 15 (按比例缩放)
  158. };
  159. } else if (isSmallScreen) {
  160. // 小屏幕 (< 700px):适度缩小
  161. return {
  162. avatarScale: 0.8,
  163. avatarTopOffset: 100,
  164. emotionTopOffset: 420,
  165. recordButtonTopOffset: 500,
  166. recordButtonSize: 88,
  167. waveformHeight: 52,
  168. verticalGap: 16,
  169. buttonGap: 35,
  170. progressRingPadding: 17, // 88 * 0.92 * 0.21 ≈ 17 (按比例缩放)
  171. };
  172. } else {
  173. // 正常屏幕:使用设计稿尺寸(393×852)
  174. return {
  175. avatarScale: 1.0,
  176. avatarTopOffset: 160,
  177. emotionTopOffset: 640,
  178. recordButtonTopOffset: 693,
  179. recordButtonSize: 101,
  180. waveformHeight: 59,
  181. verticalGap: 21,
  182. buttonGap: 37,
  183. progressRingPadding: 19,
  184. };
  185. }
  186. };
  187. /**
  188. * 监听屏幕尺寸变化
  189. */
  190. export const onScreenResize = (callback: (size: ScreenSize) => void): (() => void) => {
  191. const handler = () => {
  192. callback(getScreenSize());
  193. };
  194. window.addEventListener('resize', handler);
  195. window.addEventListener('orientationchange', handler);
  196. // 返回清理函数
  197. return () => {
  198. window.removeEventListener('resize', handler);
  199. window.removeEventListener('orientationchange', handler);
  200. };
  201. };
  202. /**
  203. * 设置极小屏幕标记(用于 CSS 选择器)
  204. * 当屏幕太小时,自动启用滚动条降级方案
  205. */
  206. export const updateTooSmallFlag = (): void => {
  207. const { isTooSmall } = getScreenSize();
  208. // 设置 HTML 元素标记
  209. document.documentElement.setAttribute('data-too-small', String(isTooSmall));
  210. // 设置 body 元素标记
  211. document.body.setAttribute('data-too-small', String(isTooSmall));
  212. // 设置 root 元素标记
  213. const root = document.getElementById('root');
  214. if (root) {
  215. root.setAttribute('data-too-small', String(isTooSmall));
  216. }
  217. // 设置所有 content-card 元素标记
  218. const cards = document.querySelectorAll('.content-card');
  219. cards.forEach(card => {
  220. card.setAttribute('data-too-small', String(isTooSmall));
  221. });
  222. // 极小屏幕已启用滚动条降级方案
  223. };
  224. // ============================================================================
  225. // V2.0: 完全计算式布局系统
  226. // ============================================================================
  227. /**
  228. * 基准布局参数(设计稿:393×852,由原 375×812 等比换算)
  229. */
  230. const BASE_LAYOUT = {
  231. // 视口
  232. baseWidth: 393,
  233. baseHeight: 852,
  234. // 安全区域
  235. topSafeArea: 46, // 顶部安全区(状态栏)
  236. bottomSafeArea: 36, // ⚠️ 已废弃:底部安全区现在使用 detectBottomSafeArea() 动态检测
  237. // 进度环容器距离底部(设计稿基准)
  238. progressContainerBottom: 88, // 进度环容器距离视口底部的距离
  239. // AnimaZone 头像区域(新设计稿)
  240. avatarWidth: 360, // 设计稿宽度
  241. avatarHeight: 357, // 设计稿高度
  242. avatarTop: 260, // 距离顶部位置
  243. avatarContainerHeight: 502,
  244. // 装饰元素
  245. treesWidth: 414,
  246. treesLeft: -27, // 设计稿中树木 left(向左偏移,部分超出屏幕)
  247. avatarCSize: 42,
  248. // 情感标签
  249. emotionTop: 53, // 距离顶部位置
  250. emotionHeight: 38,
  251. emotionGap: 21, // 情感标签与头像的间距
  252. // 录音按钮
  253. recordButtonSize: 101,
  254. recordButtonMinSize: 46,
  255. // 进度环(基于设计稿 393×852)
  256. progressRingPadding: 19,
  257. progressRingToAvatarGap: 27, // 进度环顶部距离头像底部的间距
  258. progressRingSize: 120, // 进度环容器尺寸
  259. // 波形图
  260. waveformHeight: 59,
  261. // 间距
  262. verticalGap: 21, // 波形与按钮的垂直间距
  263. buttonGap: 37, // 取消和发送按钮之间的水平间距(仅用于旧布局,V3 等比例版本中会根据按钮尺寸重新计算)
  264. minGapBetweenElements: 17, // 元素之间最小间距
  265. } as const;
  266. /**
  267. * 完全计算式布局结果
  268. */
  269. export interface CalculatedLayout {
  270. // 视口信息
  271. viewportWidth: number;
  272. viewportHeight: number;
  273. // 缩放比例
  274. avatarScale: number;
  275. // 各元素位置(绝对定位)
  276. avatarCTop: number;
  277. avatarCRight: number;
  278. treesTop: number;
  279. treesLeft: number; // 树木装饰 left 位置(自适应)
  280. animaZoneTop: number;
  281. mainAvatarTop: number;
  282. emotionTop: number;
  283. progressRingTop: number; // 进度环距离顶部
  284. progressRingBottom: number;
  285. recordButtonBottom: number;
  286. waveformBottom: number;
  287. // 各元素尺寸
  288. avatarCSize: number;
  289. treesWidth: number;
  290. mainAvatarWidth: number;
  291. mainAvatarHeight: number;
  292. emotionHeight: number;
  293. recordButtonSize: number;
  294. progressRingSize: number;
  295. waveformHeight: number;
  296. // 行为按钮(取消/发送)尺寸
  297. actionButtonSize: number;
  298. // 间距
  299. verticalGap: number;
  300. buttonGap: number; // 取消和发送按钮之间的水平间距(随按钮尺寸弹性变化)
  301. progressRingPadding: number;
  302. recordButtonOffset: number; // 录音按钮相对于进度环的 top 偏移
  303. // Tooltips 位置和尺寸(等比例缩放)
  304. tooltips: {
  305. progressBar: { width: number; height: number; top: number; left: number };
  306. tapToChat: { width: number; height: number; top: number; left: number };
  307. cancel: { width: number; height: number; bottom: string; left: string };
  308. send: { width: number; height: number; bottom: string; right: string };
  309. };
  310. // 状态标记
  311. needsScroll: boolean; // 是否需要滚动条
  312. hasOverlap: boolean; // 是否存在重叠
  313. availableSpace: number; // 剩余可用空间
  314. contentHeight: number; // 内容总高度
  315. }
  316. /**
  317. * 完全计算式布局系统 - V3.0 等比例缩放版本
  318. *
  319. * 基于 393×852 基准设计稿,进行等比例缩放适配
  320. *
  321. * @param viewportWidth 视口宽度
  322. * @param viewportHeight 视口高度
  323. * @returns 完整的布局计算结果
  324. */
  325. export function calculateLayout(
  326. viewportWidth: number = window.innerWidth,
  327. viewportHeight: number = window.innerHeight
  328. ): CalculatedLayout {
  329. const BASE = BASE_LAYOUT;
  330. // ============================================================================
  331. // V3.0: 等比例缩放策略
  332. // ============================================================================
  333. // 1. 计算缩放比例(以高度为主,宽度为辅)
  334. const scaleByHeight = viewportHeight / BASE.baseHeight;
  335. const scaleByWidth = viewportWidth / BASE.baseWidth;
  336. // 取较小的缩放比例,确保内容不会溢出
  337. let scale = Math.min(scaleByHeight, scaleByWidth);
  338. // 限制缩放范围:仅限制最小值,移除最大值限制
  339. // - 最小 0.6:避免元素过小不可点击
  340. // - 无最大值限制:完全自适应所有大屏幕
  341. scale = Math.max(0.6, scale);
  342. // 2. 基于缩放比例计算所有元素尺寸
  343. const waveformHeight = Math.round(BASE.waveformHeight * scale);
  344. const verticalGap = Math.round(BASE.verticalGap * scale);
  345. const recordButtonSize = Math.max(44, Math.round(BASE.recordButtonSize * scale)); // 最小 44px 可点击
  346. const progressRingPadding = Math.round(BASE.progressRingPadding * scale);
  347. const progressRingSize = recordButtonSize + progressRingPadding;
  348. // 行为按钮(取消/发送)尺寸与录音按钮保持 0.58 比例
  349. const actionButtonSize = Math.round(recordButtonSize * 0.58);
  350. // 为了让两按钮的中心距离约等于两个按钮宽度之和 D ≈ 2 * actionButtonSize:
  351. // 当前几何关系:中心距 D = actionButtonSize + 2 * buttonGap
  352. // 令 D = 2 * actionButtonSize,可得 buttonGap = actionButtonSize / 2
  353. const buttonGap = Math.round(actionButtonSize / 2);
  354. // 3. 动态检测底部安全区域(移动端适配)
  355. // 声波纹直接贴底,不需要安全区域
  356. const bottomSafeArea = 0; // detectBottomSafeArea();
  357. // 4. 计算顶部安全区域
  358. const topSafeArea = Math.round(BASE.topSafeArea * scale);
  359. // 5. 计算各元素尺寸(等比例缩放)
  360. const avatarCSize = Math.round(BASE.avatarCSize * scale);
  361. const treesWidth = Math.round(BASE.treesWidth * scale);
  362. const mainAvatarWidth = Math.round(BASE.avatarWidth * scale);
  363. const mainAvatarHeight = Math.round(BASE.avatarHeight * scale);
  364. const emotionHeight = Math.round(BASE.emotionHeight * scale);
  365. // 6. 计算各元素位置(等比例缩放,基于设计稿位置)
  366. // 所有位置按相同缩放比例计算,保持设计稿布局
  367. const avatarCTop = Math.round(66 * scale); // 设计稿中 Avatar-C 距顶部(393×852)
  368. const avatarCRight = Math.round(35 * scale); // 设计稿中 Avatar-C 距右侧
  369. const treesTop = Math.round(184 * scale); // 设计稿中树木距顶部
  370. const treesLeft = Math.round(BASE.treesLeft * scale); // 设计稿中树木 left: -26px(自适应)
  371. const animaZoneTop = Math.round(BASE.avatarTop * scale); // 设计稿中头像距顶部 248px
  372. const mainAvatarTop = topSafeArea; // 头像区域从安全区域开始
  373. const emotionTop = Math.round(BASE.emotionTop * scale); // 设计稿中情感标签距顶部 50px
  374. // 进度环位置 = 头像底部 + 间距(26px)
  375. const avatarBottom = animaZoneTop + mainAvatarHeight;
  376. const progressRingTop = avatarBottom + Math.round(BASE.progressRingToAvatarGap * scale);
  377. // 7. 计算底部元素位置(从下往上)
  378. // 音频波形底部保持安全区域,避免被移动端系统 UI 遮挡
  379. const waveformBottom = bottomSafeArea;
  380. // 🔧 FIX: 进度环 bottom 值 = 视口高度 - 进度环顶部 - 进度环尺寸
  381. // 这样确保进度环始终在头像下方固定距离,不受视口高度变化影响
  382. const progressRingBottom = viewportHeight - progressRingTop - progressRingSize;
  383. // 录音按钮底部位置(在进度环内居中,略微偏下)
  384. const recordButtonBottom = progressRingBottom + progressRingPadding / 2;
  385. // 8. 检查是否需要滚动
  386. // 🔧 FIX: 计算进度环实际需要的空间
  387. // 进度环底部应该距离视口底部有 bottomSafeArea 的空间
  388. const progressRingActualBottom = progressRingTop + progressRingSize;
  389. const contentHeight = progressRingActualBottom + bottomSafeArea;
  390. // 当内容高度超过视口高度,或进度环底部超出视口时,启用滚动
  391. const needsScroll = contentHeight > viewportHeight ||
  392. progressRingActualBottom > viewportHeight ||
  393. viewportHeight < 500;
  394. const hasOverlap = false; // 等比例缩放不会产生重叠
  395. const availableSpace = viewportHeight - contentHeight;
  396. // 9. 录音按钮相对于进度环的偏移
  397. const baseOffset = Math.round(5 * scale); // 基准偏移也按比例缩放
  398. const recordButtonOffset = (progressRingSize - recordButtonSize) / 2 + baseOffset;
  399. // 10. Tooltips 位置和尺寸(等比例缩放)
  400. // 设计稿基准值(393×852)
  401. // 计算 tapToChat 相对于录音按钮的偏移量
  402. const tapToChatWidth = Math.round(134 * scale);
  403. const tapToChatLeftOffset = Math.round(87 * scale); // 从录音按钮左边缘到 TapToChat 左边缘的距离
  404. const tooltips = {
  405. // 进度条提示:相对于录音按钮左上顶点定位(top: 图片高度的一半的负值,left: 图片宽度的一半的负值)
  406. progressBar: {
  407. width: Math.round(175 * scale),
  408. height: Math.round(137 * scale),
  409. top: -Math.round(137 * scale) / 2, // 图片高度的一半的负值
  410. left: -Math.round(175 * scale) / 2, // 图片宽度的一半的负值
  411. },
  412. // Tap to Chat:相对于录音按钮定位
  413. tapToChat: {
  414. width: tapToChatWidth,
  415. height: 0, // auto
  416. top: Math.round(-78 * scale), // 相对于录音按钮顶部向上偏移
  417. left: tapToChatLeftOffset, // 相对于录音按钮左边缘向右偏移
  418. },
  419. // 取消按钮提示:使用百分比定位(tooltip 放在按钮容器内)
  420. cancel: {
  421. width: Math.round(115 * scale),
  422. height: 0, // auto
  423. // bottom: 110% - tooltip 底部在按钮底部下方 10% 的位置
  424. bottom: '110%',
  425. // left: -100% - tooltip 左边缘对齐到按钮的右边缘(tooltip 在按钮左侧)
  426. left: '-100%',
  427. },
  428. // 发送按钮提示:使用百分比定位(tooltip 放在按钮容器内)
  429. send: {
  430. width: Math.round(116 * scale),
  431. height: 0, // auto
  432. // bottom: 110% - tooltip 底部在按钮底部下方 10% 的位置
  433. bottom: '110%',
  434. // right: -100% - tooltip 右边缘对齐到按钮的左边缘(tooltip 在按钮左侧)
  435. right: '-100%',
  436. },
  437. };
  438. // 11. 布局计算完成
  439. // 12. 返回完整布局
  440. return {
  441. viewportWidth,
  442. viewportHeight,
  443. avatarScale: scale, // V3.0: 统一使用缩放比例
  444. avatarCTop,
  445. avatarCRight,
  446. treesTop,
  447. treesLeft,
  448. animaZoneTop,
  449. mainAvatarTop,
  450. emotionTop,
  451. progressRingTop,
  452. progressRingBottom,
  453. recordButtonBottom,
  454. waveformBottom,
  455. avatarCSize,
  456. treesWidth,
  457. mainAvatarWidth,
  458. mainAvatarHeight,
  459. emotionHeight,
  460. recordButtonSize,
  461. progressRingSize,
  462. waveformHeight,
  463. actionButtonSize,
  464. verticalGap,
  465. buttonGap,
  466. progressRingPadding,
  467. recordButtonOffset,
  468. tooltips,
  469. needsScroll,
  470. hasOverlap,
  471. availableSpace,
  472. contentHeight,
  473. };
  474. }