File size: 4,791 Bytes
494c9e4
 
 
 
 
28147a1
 
494c9e4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28147a1
494c9e4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28147a1
 
494c9e4
 
 
 
 
 
 
 
 
966d483
 
 
 
 
 
 
 
 
 
 
 
 
494c9e4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
/**
 * 响应式设计工具模块
 * 使用 CSS 变量和 matchMedia API 实现单一数据源
 */

import { lsGet, lsRemove, lsSet } from '../storage/localStorageHelpers';

/**
 * 从 CSS 变量获取断点值
 */
const getBreakpointValue = (variableName: string): number => {
  const root = document.documentElement;
  const value = getComputedStyle(root).getPropertyValue(variableName).trim();

  if (!value) {
    throw new Error(`CSS variable ${variableName} is not defined`);
  }

  const parsed = parseInt(value, 10);
  if (isNaN(parsed)) {
    throw new Error(`CSS variable ${variableName} is not a valid number: ${value}`);
  }

  return parsed;
};

// 延迟初始化,确保 CSS 已加载
let mobileBreakpoint: number | null = null;
let mobileMediaQuery: MediaQueryList | null = null;

/**
 * 获取移动端断点查询对象
 */
const getMobileMediaQuery = (): MediaQueryList => {
  if (mobileMediaQuery === null) {
    if (mobileBreakpoint === null) {
      mobileBreakpoint = getBreakpointValue('--breakpoint-mobile');
    }
    mobileMediaQuery = window.matchMedia(`(max-width: ${mobileBreakpoint}px)`);
  }
  return mobileMediaQuery;
};

/** localStorage + 根节点 `data-force-narrow`,配套 _responsive.scss(宽屏用 :has(.main_frame)) */
export const FORCE_NARROW_STORAGE_KEY = 'info_radar_force_narrow';

/** 本标签设置或跨标签 storage 变化时都会派发(`window` 上) */
export const FORCE_NARROW_CHANGE_EVENT = 'force-narrow-change';

export const getForceNarrowScreen = (): boolean =>
  lsGet(FORCE_NARROW_STORAGE_KEY) === '1';

export const syncForceNarrowAttribute = (): void => {
  const root = document.documentElement;
  if (getForceNarrowScreen()) root.setAttribute('data-force-narrow', '');
  else root.removeAttribute('data-force-narrow');
};

let forceNarrowStorageListenerAttached = false;

/** 在入口最早调用;同步根节点并监听跨标签 storage */
export const initForceNarrowFromStorage = (): void => {
  syncForceNarrowAttribute();
  if (forceNarrowStorageListenerAttached) return;
  forceNarrowStorageListenerAttached = true;
  window.addEventListener('storage', (e: StorageEvent) => {
    if (e.key !== FORCE_NARROW_STORAGE_KEY) return;
    syncForceNarrowAttribute();
    window.dispatchEvent(new Event(FORCE_NARROW_CHANGE_EVENT));
    window.dispatchEvent(new Event('resize'));
  });
};

export const setForceNarrowScreen = (enabled: boolean): void => {
  if (enabled) lsSet(FORCE_NARROW_STORAGE_KEY, '1');
  else lsRemove(FORCE_NARROW_STORAGE_KEY);
  syncForceNarrowAttribute();
  window.dispatchEvent(new Event(FORCE_NARROW_CHANGE_EVENT));
  window.dispatchEvent(new Event('resize'));
};

/** 视口处在窄屏断点,或开启强制窄屏(未持久化时仅视口生效,与改动前一致) */
export const isNarrowScreen = (): boolean =>
  getMobileMediaQuery().matches || getForceNarrowScreen();

/**
 * 桌面端正文滚动根。
 * analysis 页把滚动下放到 `#results`(浮条留在 `.right_panel` 外);其它页仍滚 `.right_panel`。
 */
export const getDesktopTextScrollRoot = (): HTMLElement | null => {
  const results = document.getElementById('results');
  if (results) {
    const oy = getComputedStyle(results).overflowY;
    if (oy === 'auto' || oy === 'scroll') return results;
  }
  return document.querySelector('.right_panel') as HTMLElement | null;
};

/**
 * 检测是否为移动端设备(基于设备能力)
 * 移动端:有触屏支持,且没有鼠标或没有悬浮支持
 */
export const isMobileDevice = (): boolean => {
    // 检查是否有触摸支持
    const hasTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
    
    // 检查是否有鼠标(精确指针)
    const hasMouse = window.matchMedia('(pointer: fine)').matches;
    
    // 检查是否支持悬浮(hover)
    const hasHover = window.matchMedia('(hover: hover)').matches;
    
    // 移动端:有触摸支持,且没有鼠标或没有悬浮支持
    return hasTouch && (!hasMouse || !hasHover);
};

/**
 * 获取当前垂直滚动条占用的布局宽度(单位:px)
 * - 传统滚动条模式下:返回大于 0 的数值
 * - overlay 滚动条或无滚动条时:返回 0
 */
export const getVerticalScrollbarWidth = (): number => {
  // window.innerWidth: 包含垂直滚动条宽度
  // document.documentElement.clientWidth: 不包含垂直滚动条宽度
  const width = window.innerWidth - document.documentElement.clientWidth;
  return width > 0 ? width : 0;
};

/**
 * 判断当前是否使用“占用布局宽度”的传统滚动条
 * - true: 滚动条占用布局宽度(非 overlay)
 * - false: 滚动条为 overlay 或当前无滚动条
 */
export const isTraditionalScrollbar = (): boolean => getVerticalScrollbarWidth() > 0;