| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549 |
- /**
- * 屏幕尺寸检测工具
- * 用于识别小屏幕设备并提供响应式布局参数
- *
- * V2.0: 完全计算式布局系统
- * - 从可用高度出发,逆向计算所有元素位置
- * - 确保无重叠、无溢出
- * - 自动降级(滚动)
- */
- export interface ScreenSize {
- width: number;
- height: number;
- isSmallScreen: boolean;
- isVerySmallScreen: boolean;
- isTooSmall: boolean; // 极小屏幕,需要滚动条
- availableHeight: number;
- }
- /**
- * 动态检测浏览器底部安全区域
- * 用于适配移动端浏览器的系统 UI(工具栏、Home Indicator 等)
- *
- * @returns 底部安全区域的像素值(px)
- */
- export function detectBottomSafeArea(): number {
- // 方法 1: 尝试读取 CSS env(safe-area-inset-bottom)
- // 这是 iOS Safari 和部分 Android 浏览器支持的标准方法
- const testDiv = document.createElement('div');
- testDiv.style.cssText = `
- position: fixed;
- bottom: 0;
- left: 0;
- width: 1px;
- height: 1px;
- padding-bottom: env(safe-area-inset-bottom);
- pointer-events: none;
- visibility: hidden;
- `;
- document.body.appendChild(testDiv);
-
- const computedStyle = window.getComputedStyle(testDiv);
- const paddingBottom = parseFloat(computedStyle.paddingBottom);
-
- document.body.removeChild(testDiv);
-
- // 如果成功读取到安全区域值(> 0)
- if (paddingBottom > 0) {
- return paddingBottom;
- }
-
- // 方法 2: 使用 visualViewport API 检测实际可视区域
- // 某些浏览器会因为底部工具栏而缩小可视区域
- if (window.visualViewport) {
- const viewportHeight = window.visualViewport.height;
- const windowHeight = window.innerHeight;
- const difference = windowHeight - viewportHeight;
-
- // 如果差异 > 20px,认为有底部 UI 遮挡
- if (difference > 20) {
- return difference;
- }
- }
-
- // 方法 3: 根据 User Agent 提供默认值
- const ua = navigator.userAgent.toLowerCase();
-
- // iOS 设备(有 Home Indicator 或 Home 按钮)
- if (/iphone|ipad|ipod/.test(ua)) {
- // iPhone X 及以后的全面屏机型
- if (window.screen.height >= 812) {
- return 34; // Home Indicator 高度
- }
- // 老款 iPhone(有 Home 按钮)
- return 0;
- }
-
- // Android 设备
- if (/android/.test(ua)) {
- return 48; // Android 底部导航栏典型高度
- }
-
- // 桌面浏览器
- return 0;
- }
- /**
- * 获取当前屏幕尺寸信息
- */
- export const getScreenSize = (): ScreenSize => {
- const width = window.innerWidth;
- const height = window.innerHeight;
-
- // 小屏幕:高度 < 700px (iPhone SE, iPhone 8 等)
- const isSmallScreen = height < 700;
-
- // 超小屏幕:高度 < 650px (iPhone SE 第一代等)
- const isVerySmallScreen = height < 650;
-
- // 极小屏幕:宽度 < 280px 或 高度 < 500px
- // 此时即使最小缩放也无法完整显示,需要启用滚动条
- const isTooSmall = width < 280 || height < 500;
-
- // 可用高度(扣除浏览器UI)
- const availableHeight = height;
-
- return {
- width,
- height,
- isSmallScreen,
- isVerySmallScreen,
- isTooSmall,
- availableHeight,
- };
- };
- /**
- * 根据屏幕高度计算缩放比例
- * @param baseHeight 基准高度(设计稿高度,如 852px)
- * @returns 缩放比例 (0.7 - 1.0)
- */
- export const getScaleFactor = (baseHeight: number = 852): number => {
- const { height } = getScreenSize();
-
- // 计算缩放比例,最小 0.7,最大 1.0
- const scale = Math.max(0.7, Math.min(1.0, height / baseHeight));
-
- return scale;
- };
- /**
- * 根据屏幕高度获取布局参数
- */
- export interface LayoutParams {
- // 头像区域
- avatarScale: number;
- avatarTopOffset: number;
-
- // 情感标签
- emotionTopOffset: number;
-
- // 录音按钮
- recordButtonTopOffset: number;
- recordButtonSize: number;
-
- // 声纹波形
- waveformHeight: number;
-
- // 间距
- verticalGap: number;
- buttonGap: number; // 取消和发送按钮之间的水平间距
-
- // 进度环边距(进度环比录音按钮大的尺寸)
- progressRingPadding: number;
- }
- export const getLayoutParams = (): LayoutParams => {
- const { isSmallScreen, isVerySmallScreen, isTooSmall } = getScreenSize();
-
- if (isTooSmall) {
- // 极小屏幕 (< 280px 宽或 < 500px 高):最小可用尺寸
- // 此时会启用滚动条,但保证元素可点击
- return {
- avatarScale: 0.6,
- avatarTopOffset: 60,
- emotionTopOffset: 320,
- recordButtonTopOffset: 380,
- recordButtonSize: 72, // 最小可点击尺寸
- waveformHeight: 44,
- verticalGap: 10,
- buttonGap: 35, // 固定值,不缩放
- progressRingPadding: 14, // 72 * 0.75 * 0.26 ≈ 14 (按比例缩放)
- };
- } else if (isVerySmallScreen) {
- // 超小屏幕 (< 650px):大幅缩小
- return {
- avatarScale: 0.7,
- avatarTopOffset: 80,
- emotionTopOffset: 380,
- recordButtonTopOffset: 450,
- recordButtonSize: 80,
- waveformHeight: 48,
- verticalGap: 12,
- buttonGap: 35, // 固定值,不缩放
- progressRingPadding: 15, // 80 * 0.83 * 0.23 ≈ 15 (按比例缩放)
- };
- } else if (isSmallScreen) {
- // 小屏幕 (< 700px):适度缩小
- return {
- avatarScale: 0.8,
- avatarTopOffset: 100,
- emotionTopOffset: 420,
- recordButtonTopOffset: 500,
- recordButtonSize: 88,
- waveformHeight: 52,
- verticalGap: 16,
- buttonGap: 35,
- progressRingPadding: 17, // 88 * 0.92 * 0.21 ≈ 17 (按比例缩放)
- };
- } else {
- // 正常屏幕:使用设计稿尺寸(393×852)
- return {
- avatarScale: 1.0,
- avatarTopOffset: 160,
- emotionTopOffset: 640,
- recordButtonTopOffset: 693,
- recordButtonSize: 101,
- waveformHeight: 59,
- verticalGap: 21,
- buttonGap: 37,
- progressRingPadding: 19,
- };
- }
- };
- /**
- * 监听屏幕尺寸变化
- */
- export const onScreenResize = (callback: (size: ScreenSize) => void): (() => void) => {
- const handler = () => {
- callback(getScreenSize());
- };
-
- window.addEventListener('resize', handler);
- window.addEventListener('orientationchange', handler);
-
- // 返回清理函数
- return () => {
- window.removeEventListener('resize', handler);
- window.removeEventListener('orientationchange', handler);
- };
- };
- /**
- * 设置极小屏幕标记(用于 CSS 选择器)
- * 当屏幕太小时,自动启用滚动条降级方案
- */
- export const updateTooSmallFlag = (): void => {
- const { isTooSmall } = getScreenSize();
-
- // 设置 HTML 元素标记
- document.documentElement.setAttribute('data-too-small', String(isTooSmall));
-
- // 设置 body 元素标记
- document.body.setAttribute('data-too-small', String(isTooSmall));
-
- // 设置 root 元素标记
- const root = document.getElementById('root');
- if (root) {
- root.setAttribute('data-too-small', String(isTooSmall));
- }
-
- // 设置所有 content-card 元素标记
- const cards = document.querySelectorAll('.content-card');
- cards.forEach(card => {
- card.setAttribute('data-too-small', String(isTooSmall));
- });
-
- // 极小屏幕已启用滚动条降级方案
- };
- // ============================================================================
- // V2.0: 完全计算式布局系统
- // ============================================================================
- /**
- * 基准布局参数(设计稿:393×852,由原 375×812 等比换算)
- */
- const BASE_LAYOUT = {
- // 视口
- baseWidth: 393,
- baseHeight: 852,
-
- // 安全区域
- topSafeArea: 46, // 顶部安全区(状态栏)
- bottomSafeArea: 36, // ⚠️ 已废弃:底部安全区现在使用 detectBottomSafeArea() 动态检测
-
- // 进度环容器距离底部(设计稿基准)
- progressContainerBottom: 88, // 进度环容器距离视口底部的距离
-
- // AnimaZone 头像区域(新设计稿)
- avatarWidth: 360, // 设计稿宽度
- avatarHeight: 357, // 设计稿高度
- avatarTop: 260, // 距离顶部位置
- avatarContainerHeight: 502,
-
- // 装饰元素
- treesWidth: 414,
- treesLeft: -27, // 设计稿中树木 left(向左偏移,部分超出屏幕)
- avatarCSize: 42,
-
- // 情感标签
- emotionTop: 53, // 距离顶部位置
- emotionHeight: 38,
- emotionGap: 21, // 情感标签与头像的间距
-
- // 录音按钮
- recordButtonSize: 101,
- recordButtonMinSize: 46,
-
- // 进度环(基于设计稿 393×852)
- progressRingPadding: 19,
- progressRingToAvatarGap: 27, // 进度环顶部距离头像底部的间距
- progressRingSize: 120, // 进度环容器尺寸
-
- // 波形图
- waveformHeight: 59,
-
- // 间距
- verticalGap: 21, // 波形与按钮的垂直间距
- buttonGap: 37, // 取消和发送按钮之间的水平间距(仅用于旧布局,V3 等比例版本中会根据按钮尺寸重新计算)
- minGapBetweenElements: 17, // 元素之间最小间距
- } as const;
- /**
- * 完全计算式布局结果
- */
- export interface CalculatedLayout {
- // 视口信息
- viewportWidth: number;
- viewportHeight: number;
-
- // 缩放比例
- avatarScale: number;
-
- // 各元素位置(绝对定位)
- avatarCTop: number;
- avatarCRight: number;
- treesTop: number;
- treesLeft: number; // 树木装饰 left 位置(自适应)
- animaZoneTop: number;
- mainAvatarTop: number;
- emotionTop: number;
- progressRingTop: number; // 进度环距离顶部
- progressRingBottom: number;
- recordButtonBottom: number;
- waveformBottom: number;
-
- // 各元素尺寸
- avatarCSize: number;
- treesWidth: number;
- mainAvatarWidth: number;
- mainAvatarHeight: number;
- emotionHeight: number;
- recordButtonSize: number;
- progressRingSize: number;
- waveformHeight: number;
- // 行为按钮(取消/发送)尺寸
- actionButtonSize: number;
-
- // 间距
- verticalGap: number;
- buttonGap: number; // 取消和发送按钮之间的水平间距(随按钮尺寸弹性变化)
- progressRingPadding: number;
- recordButtonOffset: number; // 录音按钮相对于进度环的 top 偏移
-
- // Tooltips 位置和尺寸(等比例缩放)
- tooltips: {
- progressBar: { width: number; height: number; top: number; left: number };
- tapToChat: { width: number; height: number; top: number; left: number };
- cancel: { width: number; height: number; bottom: string; left: string };
- send: { width: number; height: number; bottom: string; right: string };
- };
-
- // 状态标记
- needsScroll: boolean; // 是否需要滚动条
- hasOverlap: boolean; // 是否存在重叠
- availableSpace: number; // 剩余可用空间
- contentHeight: number; // 内容总高度
- }
- /**
- * 完全计算式布局系统 - V3.0 等比例缩放版本
- *
- * 基于 393×852 基准设计稿,进行等比例缩放适配
- *
- * @param viewportWidth 视口宽度
- * @param viewportHeight 视口高度
- * @returns 完整的布局计算结果
- */
- export function calculateLayout(
- viewportWidth: number = window.innerWidth,
- viewportHeight: number = window.innerHeight
- ): CalculatedLayout {
- const BASE = BASE_LAYOUT;
-
- // ============================================================================
- // V3.0: 等比例缩放策略
- // ============================================================================
-
- // 1. 计算缩放比例(以高度为主,宽度为辅)
- const scaleByHeight = viewportHeight / BASE.baseHeight;
- const scaleByWidth = viewportWidth / BASE.baseWidth;
-
- // 取较小的缩放比例,确保内容不会溢出
- let scale = Math.min(scaleByHeight, scaleByWidth);
-
- // 限制缩放范围:仅限制最小值,移除最大值限制
- // - 最小 0.6:避免元素过小不可点击
- // - 无最大值限制:完全自适应所有大屏幕
- scale = Math.max(0.6, scale);
-
- // 2. 基于缩放比例计算所有元素尺寸
- const waveformHeight = Math.round(BASE.waveformHeight * scale);
- const verticalGap = Math.round(BASE.verticalGap * scale);
- const recordButtonSize = Math.max(44, Math.round(BASE.recordButtonSize * scale)); // 最小 44px 可点击
- const progressRingPadding = Math.round(BASE.progressRingPadding * scale);
- const progressRingSize = recordButtonSize + progressRingPadding;
- // 行为按钮(取消/发送)尺寸与录音按钮保持 0.58 比例
- const actionButtonSize = Math.round(recordButtonSize * 0.58);
- // 为了让两按钮的中心距离约等于两个按钮宽度之和 D ≈ 2 * actionButtonSize:
- // 当前几何关系:中心距 D = actionButtonSize + 2 * buttonGap
- // 令 D = 2 * actionButtonSize,可得 buttonGap = actionButtonSize / 2
- const buttonGap = Math.round(actionButtonSize / 2);
-
- // 3. 动态检测底部安全区域(移动端适配)
- // 声波纹直接贴底,不需要安全区域
- const bottomSafeArea = 0; // detectBottomSafeArea();
-
- // 4. 计算顶部安全区域
- const topSafeArea = Math.round(BASE.topSafeArea * scale);
-
- // 5. 计算各元素尺寸(等比例缩放)
- const avatarCSize = Math.round(BASE.avatarCSize * scale);
- const treesWidth = Math.round(BASE.treesWidth * scale);
- const mainAvatarWidth = Math.round(BASE.avatarWidth * scale);
- const mainAvatarHeight = Math.round(BASE.avatarHeight * scale);
- const emotionHeight = Math.round(BASE.emotionHeight * scale);
-
- // 6. 计算各元素位置(等比例缩放,基于设计稿位置)
- // 所有位置按相同缩放比例计算,保持设计稿布局
- const avatarCTop = Math.round(66 * scale); // 设计稿中 Avatar-C 距顶部(393×852)
- const avatarCRight = Math.round(35 * scale); // 设计稿中 Avatar-C 距右侧
- const treesTop = Math.round(184 * scale); // 设计稿中树木距顶部
- const treesLeft = Math.round(BASE.treesLeft * scale); // 设计稿中树木 left: -26px(自适应)
- const animaZoneTop = Math.round(BASE.avatarTop * scale); // 设计稿中头像距顶部 248px
- const mainAvatarTop = topSafeArea; // 头像区域从安全区域开始
- const emotionTop = Math.round(BASE.emotionTop * scale); // 设计稿中情感标签距顶部 50px
-
- // 进度环位置 = 头像底部 + 间距(26px)
- const avatarBottom = animaZoneTop + mainAvatarHeight;
- const progressRingTop = avatarBottom + Math.round(BASE.progressRingToAvatarGap * scale);
-
- // 7. 计算底部元素位置(从下往上)
- // 音频波形底部保持安全区域,避免被移动端系统 UI 遮挡
- const waveformBottom = bottomSafeArea;
- // 🔧 FIX: 进度环 bottom 值 = 视口高度 - 进度环顶部 - 进度环尺寸
- // 这样确保进度环始终在头像下方固定距离,不受视口高度变化影响
- const progressRingBottom = viewportHeight - progressRingTop - progressRingSize;
- // 录音按钮底部位置(在进度环内居中,略微偏下)
- const recordButtonBottom = progressRingBottom + progressRingPadding / 2;
-
- // 8. 检查是否需要滚动
- // 🔧 FIX: 计算进度环实际需要的空间
- // 进度环底部应该距离视口底部有 bottomSafeArea 的空间
- const progressRingActualBottom = progressRingTop + progressRingSize;
- const contentHeight = progressRingActualBottom + bottomSafeArea;
-
- // 当内容高度超过视口高度,或进度环底部超出视口时,启用滚动
- const needsScroll = contentHeight > viewportHeight ||
- progressRingActualBottom > viewportHeight ||
- viewportHeight < 500;
- const hasOverlap = false; // 等比例缩放不会产生重叠
- const availableSpace = viewportHeight - contentHeight;
-
- // 9. 录音按钮相对于进度环的偏移
- const baseOffset = Math.round(5 * scale); // 基准偏移也按比例缩放
- const recordButtonOffset = (progressRingSize - recordButtonSize) / 2 + baseOffset;
-
- // 10. Tooltips 位置和尺寸(等比例缩放)
- // 设计稿基准值(393×852)
- // 计算 tapToChat 相对于录音按钮的偏移量
- const tapToChatWidth = Math.round(134 * scale);
- const tapToChatLeftOffset = Math.round(87 * scale); // 从录音按钮左边缘到 TapToChat 左边缘的距离
-
- const tooltips = {
- // 进度条提示:相对于录音按钮左上顶点定位(top: 图片高度的一半的负值,left: 图片宽度的一半的负值)
- progressBar: {
- width: Math.round(175 * scale),
- height: Math.round(137 * scale),
- top: -Math.round(137 * scale) / 2, // 图片高度的一半的负值
- left: -Math.round(175 * scale) / 2, // 图片宽度的一半的负值
- },
- // Tap to Chat:相对于录音按钮定位
- tapToChat: {
- width: tapToChatWidth,
- height: 0, // auto
- top: Math.round(-78 * scale), // 相对于录音按钮顶部向上偏移
- left: tapToChatLeftOffset, // 相对于录音按钮左边缘向右偏移
- },
- // 取消按钮提示:使用百分比定位(tooltip 放在按钮容器内)
- cancel: {
- width: Math.round(115 * scale),
- height: 0, // auto
- // bottom: 110% - tooltip 底部在按钮底部下方 10% 的位置
- bottom: '110%',
- // left: -100% - tooltip 左边缘对齐到按钮的右边缘(tooltip 在按钮左侧)
- left: '-100%',
- },
- // 发送按钮提示:使用百分比定位(tooltip 放在按钮容器内)
- send: {
- width: Math.round(116 * scale),
- height: 0, // auto
- // bottom: 110% - tooltip 底部在按钮底部下方 10% 的位置
- bottom: '110%',
- // right: -100% - tooltip 右边缘对齐到按钮的左边缘(tooltip 在按钮左侧)
- right: '-100%',
- },
- };
-
- // 11. 布局计算完成
-
- // 12. 返回完整布局
- return {
- viewportWidth,
- viewportHeight,
- avatarScale: scale, // V3.0: 统一使用缩放比例
- avatarCTop,
- avatarCRight,
- treesTop,
- treesLeft,
- animaZoneTop,
- mainAvatarTop,
- emotionTop,
- progressRingTop,
- progressRingBottom,
- recordButtonBottom,
- waveformBottom,
- avatarCSize,
- treesWidth,
- mainAvatarWidth,
- mainAvatarHeight,
- emotionHeight,
- recordButtonSize,
- progressRingSize,
- waveformHeight,
- actionButtonSize,
- verticalGap,
- buttonGap,
- progressRingPadding,
- recordButtonOffset,
- tooltips,
- needsScroll,
- hasOverlap,
- availableSpace,
- contentHeight,
- };
- }
|