clxx 2.1.4 → 2.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,8 +46,8 @@ pnpm add clxx
46
46
 
47
47
  ```json
48
48
  {
49
- "react": "^19.2.0",
50
- "react-dom": "^19.2.0"
49
+ "react": "^19.2.4",
50
+ "react-dom": "^19.2.4"
51
51
  }
52
52
  ```
53
53
 
@@ -67,7 +67,7 @@ export const style = {
67
67
  marginLeft: "-.5px",
68
68
  width: "1px",
69
69
  backgroundColor: "#c0c0c0",
70
- transform: `scale(${1 / devicePixelRatio}, 1)`,
70
+ transform: `scale(${1 / window.devicePixelRatio}, 1)`,
71
71
  },
72
72
  },
73
73
  btn: [
@@ -27,15 +27,22 @@ export function AutoGrid(props) {
27
27
  // 生成一个能创建表格的二维数组
28
28
  let list = [];
29
29
  React.Children.forEach(children, (child) => {
30
- if (child !== null) {
30
+ if (child !== null && child !== undefined) {
31
31
  if (list.length === 0 || list[list.length - 1].length >= cols) {
32
32
  list.push([]);
33
33
  }
34
34
  list[list.length - 1].push(child);
35
35
  }
36
36
  });
37
+ // 用空占位符补齐最后一行,避免最后一行元素宽度不一致
38
+ if (list.length > 0) {
39
+ const lastRow = list[list.length - 1];
40
+ while (lastRow.length < cols) {
41
+ lastRow.push(_jsx("div", { style: { visibility: 'hidden' } }, `placeholder-${lastRow.length}`));
42
+ }
43
+ }
37
44
  return list;
38
- }, [children]);
45
+ }, [children, cols]);
39
46
  // 元素的最终样式
40
47
  const finalItemBoxStyle = [
41
48
  style.itemBoxStyle,
@@ -24,13 +24,7 @@ export function Clickable(props) {
24
24
  }, [activeClassName, activeStyle]);
25
25
  const finalActiveStyle = defaultActiveStyle || activeStyle;
26
26
  const touchable = is('touchable');
27
- const [boxClass, setBoxClass] = useState(className);
28
- const [boxStyle, setBoxStyle] = useState(style);
29
- // 监控属性的更新
30
- useEffect(() => {
31
- setBoxClass(className);
32
- setBoxStyle(style);
33
- }, [className, style]);
27
+ const [isActive, setIsActive] = useState(false);
34
28
  // 标记是否正处于触摸状态
35
29
  const touchRef = useRef(false);
36
30
  const onStart = (event) => {
@@ -40,26 +34,15 @@ export function Clickable(props) {
40
34
  if (!bubble) {
41
35
  event.stopPropagation();
42
36
  }
43
- // 激活目标样式
44
- if (typeof activeClassName === 'string') {
45
- setBoxClass(typeof boxClass === 'string'
46
- ? `${boxClass} ${activeClassName}`
47
- : activeClassName);
48
- }
49
- if (typeof finalActiveStyle === 'object') {
50
- setBoxStyle(typeof boxStyle === 'object'
51
- ? Object.assign(Object.assign({}, boxStyle), finalActiveStyle) : finalActiveStyle);
52
- }
37
+ setIsActive(true);
53
38
  }
54
39
  };
55
- // onEnd返回记忆的版本,防止下一个effect中无意义重复执行
56
40
  const onEnd = useCallback(() => {
57
41
  if (touchRef.current) {
58
42
  touchRef.current = false;
59
- setBoxClass(className);
60
- setBoxStyle(style);
43
+ setIsActive(false);
61
44
  }
62
- }, [className, style]);
45
+ }, []);
63
46
  // PC环境释放逻辑
64
47
  useEffect(() => {
65
48
  if (!disable && !touchable) {
@@ -70,7 +53,14 @@ export function Clickable(props) {
70
53
  };
71
54
  }
72
55
  }, [disable, touchable, onEnd]);
73
- const fullAttrs = Object.assign(Object.assign({}, attrs), { className: boxClass, style: boxStyle });
56
+ // 根据激活状态计算最终的 className style
57
+ const finalClassName = isActive && typeof activeClassName === 'string'
58
+ ? (typeof className === 'string' ? `${className} ${activeClassName}` : activeClassName)
59
+ : className;
60
+ const finalStyle = isActive && typeof finalActiveStyle === 'object'
61
+ ? (typeof style === 'object' ? Object.assign(Object.assign({}, style), finalActiveStyle) : finalActiveStyle)
62
+ : style;
63
+ const fullAttrs = Object.assign(Object.assign({}, attrs), { className: finalClassName, style: finalStyle });
74
64
  // 非禁用状态有点击态行为
75
65
  if (!disable) {
76
66
  if (touchable) {
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "@emotion/react/jsx-runtime";
2
2
  import { Global } from "@emotion/react";
3
- import React, { useCallback, useEffect, useMemo, useState } from "react";
3
+ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useState, } from "react";
4
4
  import { getContextValue } from "../context";
5
5
  import { useWindowResize } from "../Effect/useWindowResize";
6
6
  import { useViewport } from "../Effect/useViewport";
@@ -13,85 +13,72 @@ export function Container(props) {
13
13
  const { minDocWidth, maxDocWidth } = getContextValue();
14
14
  // 获取环境变量
15
15
  const { designWidth = 750, globalStyle, children } = props;
16
- // 计算根字体尺寸的函数(使用 useCallback 避免重复创建)
16
+ // 计算理论根字体大小(未经浏览器缩放修正)
17
17
  const calculateFontSize = useCallback((width) => {
18
- let targetWidth = width;
19
- if (width >= maxDocWidth) {
20
- targetWidth = maxDocWidth;
21
- }
22
- else if (width <= minDocWidth) {
23
- targetWidth = minDocWidth;
24
- }
18
+ const targetWidth = Math.min(Math.max(width, minDocWidth), maxDocWidth);
25
19
  return (targetWidth * 100) / designWidth;
26
20
  }, [designWidth, minDocWidth, maxDocWidth]);
27
- // 基准字体尺寸(初始化时计算一次)
28
- const [baseFontSize, setBaseFontSize] = useState(() => calculateFontSize(window.innerWidth));
29
- // 是否已完成初始化(包括字体缩放修正)
21
+ // 理论基准字体大小(跟随窗口尺寸变化)
22
+ const [rawFontSize, setRawFontSize] = useState(() => calculateFontSize(window.innerWidth));
23
+ // 浏览器字体缩放因子(>1 表示用户放大了系统字体,<1 表示缩小)
24
+ // 独立存储,使得 resize 后缩放修正依然生效
25
+ const [scaleFactor, setScaleFactor] = useState(1);
26
+ // 是否已完成字体缩放检测
30
27
  const [isInitialized, setIsInitialized] = useState(false);
31
- // 字体缩放修正逻辑(处理浏览器字体设置影响)
32
- // 使用 useLayoutEffect DOM 更新后立即同步执行,避免闪烁
33
- useEffect(() => {
34
- // 只在未初始化时检查一次
28
+ // 修正后的字体大小:统一对所有字体计算应用缩放修正
29
+ const correctedFontSize = useMemo(() => scaleFactor === 1
30
+ ? rawFontSize
31
+ : Math.round((rawFontSize / scaleFactor) * 10) / 10, [rawFontSize, scaleFactor]);
32
+ // 字体缩放检测
33
+ // Emotion 的 <Global> 通过 useInsertionEffect 注入样式,早于 useLayoutEffect
34
+ // 因此 useLayoutEffect 内 getComputedStyle 可正确读取已注入的字体大小
35
+ // 检测和修正均在浏览器绘制前同步完成,避免闪烁
36
+ useLayoutEffect(() => {
37
+ // 缩放因子在页面生命周期内不变,只需检测一次
35
38
  if (isInitialized)
36
39
  return;
37
- // 延迟到下一帧检查,确保 DOM 已经应用了 baseFontSize
38
- requestAnimationFrame(() => {
39
- const computedSize = parseFloat(window.getComputedStyle(document.documentElement).fontSize);
40
- // 如果计算出的字体大小与期望不符(说明被浏览器字体设置影响了)
41
- // 使用较大的容差值,避免浮点数精度问题导致的无限循环
42
- if (typeof computedSize === "number" &&
43
- computedSize > 0 &&
44
- Math.abs(computedSize - baseFontSize) > 1 // 容差 1px,避免过度敏感
45
- ) {
46
- // 计算浏览器的字体缩放比例
47
- const scaleFactor = computedSize / baseFontSize;
48
- // 通过反向缩放修正字体大小
49
- // 例如:期望 50px,实际 60px(1.2倍),则设置 50/1.2 ≈ 41.67px
50
- const correctedSize = Math.round((baseFontSize / scaleFactor) * 10) / 10;
51
- // 只修正一次,然后标记为已初始化
52
- setBaseFontSize(correctedSize);
53
- setIsInitialized(true);
54
- }
55
- else {
56
- // 字体大小正确,直接标记为已初始化
57
- setIsInitialized(true);
58
- }
59
- });
60
- }, [baseFontSize, isInitialized]);
61
- // 页面大小变化时,基准字体同步变化
62
- // 使用 requestAnimationFrame 批量处理,避免频繁更新
40
+ const computedSize = parseFloat(window.getComputedStyle(document.documentElement).fontSize);
41
+ // 如果计算出的字体大小与期望不符(说明被浏览器字体设置影响了)
42
+ // 容差 1px,避免浮点精度导致误判
43
+ if (computedSize > 0 && Math.abs(computedSize - rawFontSize) > 1) {
44
+ // 记录缩放因子,后续所有字体计算(包括 resize)都会自动应用
45
+ setScaleFactor(computedSize / rawFontSize);
46
+ }
47
+ setIsInitialized(true);
48
+ }, [rawFontSize, isInitialized]);
49
+ // 窗口大小变化时更新理论字体大小
50
+ // correctedFontSize 通过 useMemo 自动应用 scaleFactor 修正
63
51
  useWindowResize(() => {
64
- requestAnimationFrame(() => {
65
- const newFontSize = calculateFontSize(window.innerWidth);
66
- if (newFontSize !== baseFontSize) {
67
- setBaseFontSize(newFontSize);
68
- }
69
- });
52
+ setRawFontSize(calculateFontSize(window.innerWidth));
70
53
  });
71
- // 设置meta, 确保viewport的合法逻辑
54
+ // 设置 viewport meta
72
55
  useViewport();
73
- // 页面初始化逻辑
56
+ // 激活 iOS 上的 :active 伪类
74
57
  useEffect(() => {
75
- // 激活iOS上的:active伪类
76
- const activable = () => { };
77
- document.body.addEventListener("touchstart", activable, { passive: true });
58
+ const noop = () => { };
59
+ document.body.addEventListener("touchstart", noop, { passive: true });
78
60
  return () => {
79
- document.body.removeEventListener("touchstart", activable);
61
+ document.body.removeEventListener("touchstart", noop);
80
62
  };
81
63
  }, []);
82
- // 使用 useMemo 缓存媒体查询样式,避免每次渲染都重新计算
83
- const mediaQueryStyles = useMemo(() => ({
84
- [`@media (min-width: ${maxDocWidth}px)`]: {
85
- html: {
86
- fontSize: `${(100 * maxDocWidth) / designWidth}px`,
64
+ // 媒体查询边界样式(同样应用缩放修正,与 JS 计算保持一致)
65
+ const mediaQueryStyles = useMemo(() => {
66
+ const correct = (size) => scaleFactor === 1
67
+ ? size
68
+ : Math.round((size / scaleFactor) * 10) / 10;
69
+ return {
70
+ [`@media (min-width: ${maxDocWidth}px)`]: {
71
+ html: {
72
+ fontSize: `${correct((100 * maxDocWidth) / designWidth)}px`,
73
+ },
87
74
  },
88
- },
89
- [`@media (max-width: ${minDocWidth}px)`]: {
90
- html: {
91
- fontSize: `${(100 * minDocWidth) / designWidth}px`,
75
+ [`@media (max-width: ${minDocWidth}px)`]: {
76
+ html: {
77
+ fontSize: `${correct((100 * minDocWidth) / designWidth)}px`,
78
+ },
92
79
  },
93
- },
94
- }), [designWidth, minDocWidth, maxDocWidth]);
80
+ };
81
+ }, [designWidth, minDocWidth, maxDocWidth, scaleFactor]);
95
82
  return (_jsxs(React.Fragment, { children: [_jsx(Global, { styles: [
96
83
  Object.assign({ "*": {
97
84
  boxSizing: "border-box",
@@ -99,7 +86,7 @@ export function Container(props) {
99
86
  WebkitTapHighlightColor: "transparent",
100
87
  WebkitOverflowScrolling: "touch",
101
88
  WebkitTextSizeAdjust: "100%",
102
- fontSize: `${baseFontSize}px`,
89
+ fontSize: `${correctedFontSize}px`,
103
90
  touchAction: "manipulation",
104
91
  }, body: {
105
92
  fontSize: "16px",
@@ -117,7 +117,7 @@ export function getAnimation(type, status) {
117
117
  return {
118
118
  keyframes,
119
119
  animation: css({
120
- animation: `${keyframes} 300ms ease`,
120
+ animation: `${keyframes} 300ms ease forwards`,
121
121
  }),
122
122
  };
123
123
  }
@@ -126,7 +126,7 @@ export const style = {
126
126
  animation: `${maskShow} 300ms ease`,
127
127
  }),
128
128
  maskHide: css({
129
- animation: `${maskHide} 300ms ease`,
129
+ animation: `${maskHide} 300ms ease forwards`,
130
130
  }),
131
131
  mask: css({
132
132
  zIndex: 1,
@@ -10,7 +10,7 @@ var __rest = (this && this.__rest) || function (s, e) {
10
10
  return t;
11
11
  };
12
12
  import { jsx as _jsx } from "@emotion/react/jsx-runtime";
13
- import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
13
+ import { useLayoutEffect, useMemo, useState } from "react";
14
14
  import { createPortal } from "react-dom";
15
15
  import { getContextValue } from "../context";
16
16
  import { useWindowResize } from "../Effect/useWindowResize";
@@ -23,13 +23,6 @@ export function Overlay(props) {
23
23
  const { children, outside = false, centerContent = true, fullScreen = true, maskColor = "rgba(0, 0, 0, .6)" } = props, extra = __rest(props, ["children", "outside", "centerContent", "fullScreen", "maskColor"]);
24
24
  const [mount, setMount] = useState(null);
25
25
  const [innerWidth, setInnerWidth] = useState(window.innerWidth);
26
- // 这里是为了修复一个非挂载状态触发resize事件的bug
27
- const isUnmount = useRef(false);
28
- useEffect(() => {
29
- return () => {
30
- isUnmount.current = true;
31
- };
32
- }, []);
33
26
  useLayoutEffect(() => {
34
27
  if (outside) {
35
28
  const div = document.createElement("div");
@@ -42,9 +35,7 @@ export function Overlay(props) {
42
35
  }, [outside]);
43
36
  // 页面大小变化时,innerWidth 也会更新
44
37
  useWindowResize(() => {
45
- if (!isUnmount.current) {
46
- setInnerWidth(window.innerWidth);
47
- }
38
+ setInnerWidth(window.innerWidth);
48
39
  });
49
40
  const ctx = getContextValue();
50
41
  // 使用 useMemo 缓存样式计算,避免每次渲染都重新计算
@@ -10,7 +10,7 @@ var __rest = (this && this.__rest) || function (s, e) {
10
10
  return t;
11
11
  };
12
12
  import { jsx as _jsx, jsxs as _jsxs } from "@emotion/react/jsx-runtime";
13
- import { useCallback, useLayoutEffect, useRef, useState } from "react";
13
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
14
14
  import { Indicator } from "../Indicator";
15
15
  import { RowCenter } from "../Flex/Row";
16
16
  import { style } from "./style";
@@ -31,19 +31,22 @@ export function ScrollView(props) {
31
31
  // 节流控制
32
32
  const throttleTimer = useRef(undefined);
33
33
  const lastCallTime = useRef(0);
34
- // 使用 ref 保存最新的回调函数,避免闭包陈旧
35
- const callbacksRef = useRef({
34
+ // 使用 ref 保存所有滚动处理需要的 props,彻底消除陈旧闭包
35
+ const propsRef = useRef({
36
36
  onScroll,
37
37
  onReachTop,
38
38
  onReachBottom,
39
+ reachTopThreshold,
40
+ reachBottomThreshold,
39
41
  });
40
- // 每次渲染都更新 ref 中的回调
41
- callbacksRef.current = {
42
+ propsRef.current = {
42
43
  onScroll,
43
44
  onReachTop,
44
45
  onReachBottom,
46
+ reachTopThreshold,
47
+ reachBottomThreshold,
45
48
  };
46
- // container是否有滚动条
49
+ // container 是否有滚动条
47
50
  const [hasScrollBar, setHasScrollBar] = useState(false);
48
51
  // 检查是否有滚动条
49
52
  const checkScrollBar = useCallback(() => {
@@ -72,41 +75,24 @@ export function ScrollView(props) {
72
75
  resizeObserver.disconnect();
73
76
  };
74
77
  }, [checkScrollBar]);
75
- // 滚动回调(带节流)
76
- const scrollCallback = useCallback((rawEvent) => {
77
- const now = Date.now();
78
- // 节流控制
79
- if (scrollThrottle > 0 && now - lastCallTime.current < scrollThrottle) {
80
- // 清除之前的定时器
81
- if (throttleTimer.current) {
82
- clearTimeout(throttleTimer.current);
83
- }
84
- // 设置新的定时器,确保最后一次调用会被执行
85
- throttleTimer.current = window.setTimeout(() => {
86
- handleScroll(rawEvent);
87
- }, scrollThrottle);
88
- return;
89
- }
90
- lastCallTime.current = now;
91
- handleScroll(rawEvent);
92
- }, [scrollThrottle, reachTopThreshold, reachBottomThreshold]);
93
- // 实际的滚动处理逻辑
94
- const handleScroll = useCallback((rawEvent) => {
95
- var _a, _b, _c, _d, _e, _f;
78
+ // 核心滚动处理逻辑
79
+ // 所有外部值从 ref 读取,deps 为空,引用永远稳定,不存在闭包过期问题
80
+ const processScroll = useCallback((rawEvent) => {
96
81
  const box = container.current;
97
82
  if (!box)
98
83
  return;
99
- // 已经滚动的距离
84
+ const { onScroll, onReachTop, onReachBottom, reachTopThreshold, reachBottomThreshold, } = propsRef.current;
100
85
  const scrollTop = box.scrollTop;
101
- // 滚动容器的包含滚动内容的高度
102
86
  const contentHeight = box.scrollHeight;
103
- // 滚动容器的视口高度
104
- const containerHeight = Math.min(box.clientHeight, box.offsetHeight);
105
- // 最大可滚动距离
87
+ // clientHeight 即可视区域高度(不含 border),无需 Math.min(clientHeight, offsetHeight)
88
+ const containerHeight = box.clientHeight;
106
89
  const maxScroll = contentHeight - containerHeight;
107
- // 计算滚动方向
108
- const direction = scrollTop > lastScrollTop.current ? "downward" : "upward";
109
- // 生成滚动事件参数
90
+ // 防止零位移时误判方向(如内容变化触发的 scroll 事件)
91
+ if (scrollTop === lastScrollTop.current && lastScrollTop.current !== 0) {
92
+ return;
93
+ }
94
+ // scrollTop 增大 => 向下滚动;相等(初始 0→0)视为向下
95
+ const direction = scrollTop >= lastScrollTop.current ? "downward" : "upward";
110
96
  const event = {
111
97
  containerHeight,
112
98
  contentHeight,
@@ -115,42 +101,74 @@ export function ScrollView(props) {
115
101
  direction,
116
102
  rawEvent,
117
103
  };
118
- // 调用通用滚动事件(使用 ref 中的最新回调)
119
- (_b = (_a = callbacksRef.current).onScroll) === null || _b === void 0 ? void 0 : _b.call(_a, event);
104
+ onScroll === null || onScroll === void 0 ? void 0 : onScroll(event);
120
105
  // 触顶逻辑(防止重复触发)
121
106
  if (direction === "upward" && scrollTop <= reachTopThreshold) {
122
107
  if (!hasReachedTop.current) {
123
108
  hasReachedTop.current = true;
124
- hasReachedBottom.current = false; // 重置触底标记
125
- (_d = (_c = callbacksRef.current).onReachTop) === null || _d === void 0 ? void 0 : _d.call(_c, event);
109
+ hasReachedBottom.current = false;
110
+ onReachTop === null || onReachTop === void 0 ? void 0 : onReachTop(event);
126
111
  }
127
112
  }
128
113
  else if (scrollTop > reachTopThreshold) {
129
114
  hasReachedTop.current = false;
130
115
  }
131
116
  // 触底逻辑(防止重复触发)
132
- if (direction === "downward" && scrollTop >= maxScroll - reachBottomThreshold) {
117
+ if (direction === "downward" &&
118
+ maxScroll > 0 &&
119
+ scrollTop >= maxScroll - reachBottomThreshold) {
133
120
  if (!hasReachedBottom.current) {
134
121
  hasReachedBottom.current = true;
135
- hasReachedTop.current = false; // 重置触顶标记
136
- (_f = (_e = callbacksRef.current).onReachBottom) === null || _f === void 0 ? void 0 : _f.call(_e, event);
122
+ hasReachedTop.current = false;
123
+ onReachBottom === null || onReachBottom === void 0 ? void 0 : onReachBottom(event);
137
124
  }
138
125
  }
139
126
  else if (scrollTop < maxScroll - reachBottomThreshold) {
140
127
  hasReachedBottom.current = false;
141
128
  }
142
- // 更新scrollTop上次的值
143
129
  lastScrollTop.current = scrollTop;
144
- }, [reachTopThreshold, reachBottomThreshold]);
130
+ }, []);
131
+ // 节流滚动回调(leading + trailing)
132
+ const scrollCallback = useCallback((rawEvent) => {
133
+ // 不节流时直接执行
134
+ if (scrollThrottle <= 0) {
135
+ processScroll(rawEvent);
136
+ return;
137
+ }
138
+ const now = Date.now();
139
+ const elapsed = now - lastCallTime.current;
140
+ if (elapsed >= scrollThrottle) {
141
+ // 前沿立即执行
142
+ lastCallTime.current = now;
143
+ processScroll(rawEvent);
144
+ // 消除挂起的尾部定时器
145
+ if (throttleTimer.current !== undefined) {
146
+ clearTimeout(throttleTimer.current);
147
+ throttleTimer.current = undefined;
148
+ }
149
+ }
150
+ else {
151
+ // 尾部调用:按剩余时间调度,保证滚动停止后最终状态被处理
152
+ if (throttleTimer.current !== undefined) {
153
+ clearTimeout(throttleTimer.current);
154
+ }
155
+ throttleTimer.current = window.setTimeout(() => {
156
+ lastCallTime.current = Date.now();
157
+ throttleTimer.current = undefined;
158
+ // 尾部调用不传 rawEvent(已过期),processScroll 从 DOM 读取实时位置
159
+ processScroll();
160
+ }, scrollThrottle - elapsed);
161
+ }
162
+ }, [scrollThrottle, processScroll]);
145
163
  // 清理节流定时器
146
- useLayoutEffect(() => {
164
+ useEffect(() => {
147
165
  return () => {
148
- if (throttleTimer.current) {
166
+ if (throttleTimer.current !== undefined) {
149
167
  clearTimeout(throttleTimer.current);
150
168
  }
151
169
  };
152
170
  }, []);
153
- // loading内容
171
+ // loading 内容
154
172
  let showLoadingContent = null;
155
173
  if (showLoading) {
156
174
  if (!loadingContent) {
@@ -26,11 +26,13 @@ export function showToast(option) {
26
26
  */
27
27
  let portalDOM = null;
28
28
  export function showUniqToast(option) {
29
- if (!portalDOM) {
30
- portalDOM = createPortalDOM();
29
+ // 先清理上一个 Toast 的 DOM 容器,避免快速连续调用时旧容器泄漏
30
+ if (portalDOM) {
31
+ portalDOM.unmount();
32
+ portalDOM = null;
31
33
  }
34
+ portalDOM = createPortalDOM();
32
35
  let props = {};
33
- // 默认Toast是唯一的
34
36
  if (React.isValidElement(option) || typeof option !== 'object') {
35
37
  props.content = option;
36
38
  }
@@ -14,8 +14,14 @@ export class Countdown {
14
14
  * s:秒
15
15
  */
16
16
  this.format = ['d', 'h', 'i', 's'];
17
- if (typeof option.remain === 'number' && option.remain >= 0) {
18
- this.total = this.remain = option.remain;
17
+ if (typeof option.remain === 'string') {
18
+ const parsed = parseFloat(option.remain);
19
+ if (!isNaN(parsed) && parsed >= 0) {
20
+ this.total = this.remain = Math.floor(parsed);
21
+ }
22
+ }
23
+ else if (typeof option.remain === 'number' && option.remain >= 0) {
24
+ this.total = this.remain = Math.floor(option.remain);
19
25
  }
20
26
  // 倒计时需要展示的时间格式
21
27
  if (typeof option.format === 'string') {
@@ -75,11 +81,12 @@ export class Countdown {
75
81
  }
76
82
  }, 1000); // ← 添加 1000ms 间隔
77
83
  }
78
- // 停止倒计时
84
+ // 停止倒计时(暂停并保留当前剩余时间,可再次 start 恢复)
79
85
  stop() {
80
86
  var _a;
81
- this.total = this.remain;
82
87
  (_a = this._stopTick) === null || _a === void 0 ? void 0 : _a.call(this);
88
+ this._stopTick = undefined;
89
+ this.total = this.remain;
83
90
  }
84
91
  /**
85
92
  * 格式化每次更新的值
@@ -6,80 +6,67 @@ import dayjs from 'dayjs';
6
6
  export function ago(date) {
7
7
  const now = dayjs();
8
8
  const input = dayjs(date);
9
- const aYearAgo = now.subtract(1, 'year');
10
- const aMonthAgo = now.subtract(1, 'month');
11
- const aDayAgo = now.subtract(1, 'day');
12
- const aHourAgo = now.subtract(1, 'hour');
13
- const aMinuteAgo = now.subtract(1, 'minute');
14
- // 多少年前
15
- if (input.isBefore(aYearAgo)) {
16
- const diff = now.year() - input.year();
17
- const nYearsAgo = now.subtract(diff, 'year');
18
- let showNum = diff;
19
- if (input.isAfter(nYearsAgo)) {
20
- showNum = diff - 1;
21
- }
9
+ if (!input.isValid()) {
22
10
  return {
23
- num: showNum,
11
+ num: 0,
12
+ unit: 's',
13
+ format: '刚刚',
14
+ };
15
+ }
16
+ const isFuture = input.isAfter(now);
17
+ const from = isFuture ? now : input;
18
+ const to = isFuture ? input : now;
19
+ const years = to.diff(from, 'year');
20
+ if (years >= 1) {
21
+ return {
22
+ num: years,
24
23
  unit: 'y',
25
- format: `${showNum}年前`,
24
+ format: `${years}年${isFuture ? '后' : '前'}`,
26
25
  };
27
26
  }
28
- // 多少月前
29
- if (input.isBefore(aMonthAgo)) {
30
- let showNum = 1;
31
- for (let n = 2; n <= 12; n++) {
32
- const nMonthAgo = now.subtract(n, 'month');
33
- if (input.isAfter(nMonthAgo)) {
34
- showNum = n - 1;
35
- break;
36
- }
37
- }
27
+ const months = to.diff(from, 'month');
28
+ if (months >= 1) {
38
29
  return {
39
- num: showNum,
30
+ num: months,
40
31
  unit: 'm',
41
- format: `${showNum}个月前`,
32
+ format: `${months}个月${isFuture ? '后' : '前'}`,
42
33
  };
43
34
  }
44
- // 多少天前
45
- if (input.isBefore(aDayAgo)) {
46
- const showNum = Math.floor((now.unix() - input.unix()) / 86400);
35
+ const days = to.diff(from, 'day');
36
+ if (days >= 1) {
47
37
  return {
48
- num: showNum,
38
+ num: days,
49
39
  unit: 'd',
50
- format: `${showNum}天前`,
40
+ format: `${days}天${isFuture ? '后' : '前'}`,
51
41
  };
52
42
  }
53
- // 多少小时前
54
- if (input.isBefore(aHourAgo)) {
55
- const showNum = Math.floor((now.unix() - input.unix()) / 3600);
43
+ const hours = to.diff(from, 'hour');
44
+ if (hours >= 1) {
56
45
  return {
57
- num: showNum,
46
+ num: hours,
58
47
  unit: 'h',
59
- format: `${showNum}个小时前`,
48
+ format: `${hours}小时${isFuture ? '后' : '前'}`,
60
49
  };
61
50
  }
62
- // 多少分钟前
63
- if (input.isBefore(aMinuteAgo)) {
64
- const showNum = Math.floor((now.unix() - input.unix()) / 60);
51
+ const minutes = to.diff(from, 'minute');
52
+ if (minutes >= 1) {
65
53
  return {
66
- num: showNum,
54
+ num: minutes,
67
55
  unit: 'i',
68
- format: `${showNum}分钟前`,
56
+ format: `${minutes}分钟${isFuture ? '后' : '前'}`,
69
57
  };
70
58
  }
71
- // 多少秒前
72
- const showNum = now.unix() - input.unix();
73
- let format;
74
- if (showNum > 10) {
75
- format = `${showNum}秒前`;
76
- }
77
- else {
78
- format = '刚刚';
59
+ const seconds = to.diff(from, 'second');
60
+ if (seconds < 10) {
61
+ return {
62
+ num: seconds,
63
+ unit: 's',
64
+ format: isFuture ? '马上' : '刚刚',
65
+ };
79
66
  }
80
67
  return {
81
- num: showNum,
68
+ num: seconds,
82
69
  unit: 's',
83
- format,
70
+ format: `${seconds}秒${isFuture ? '后' : '前'}`,
84
71
  };
85
72
  }
@@ -79,23 +79,38 @@ export function createApp(option) {
79
79
  yield (onBefore === null || onBefore === void 0 ? void 0 : onBefore(normalizedPath));
80
80
  // 加载并显示页面
81
81
  if (typeof render === "function") {
82
- const pageContent = yield render(normalizedPath);
83
- // 如果返回 null/undefined,视为页面未找到
84
- if (pageContent === null || pageContent === undefined) {
82
+ try {
83
+ const pageContent = yield render(normalizedPath);
84
+ // 如果返回 null/undefined,视为页面未找到
85
+ if (pageContent === null || pageContent === undefined) {
86
+ if (typeof notFound === "function") {
87
+ setPage(yield notFound(normalizedPath));
88
+ }
89
+ else {
90
+ // 默认 404 页面
91
+ setPage(_jsxs("div", { children: ["Not Found: ", normalizedPath] }));
92
+ }
93
+ return;
94
+ }
95
+ setPage(pageContent);
96
+ }
97
+ catch (_a) {
98
+ // 动态 import 失败等场景
85
99
  if (typeof notFound === "function") {
86
100
  setPage(yield notFound(normalizedPath));
87
101
  }
88
102
  else {
89
- // 默认 404 页面
90
103
  setPage(_jsxs("div", { children: ["Not Found: ", normalizedPath] }));
91
104
  }
92
105
  return;
93
106
  }
94
- setPage(pageContent);
95
107
  }
96
108
  // 页面加载后钩子
97
109
  yield (onAfter === null || onAfter === void 0 ? void 0 : onAfter(normalizedPath));
98
- }), [onBefore, onAfter, loading, render, notFound]);
110
+ }),
111
+ // 所有外部变量在闭包创建时已捕获,不会变化
112
+ // eslint-disable-next-line react-hooks/exhaustive-deps
113
+ []);
99
114
  /**
100
115
  * 监听路由变化
101
116
  */
@@ -119,8 +134,8 @@ export function createApp(option) {
119
134
  else if (option.target instanceof HTMLElement) {
120
135
  mount = option.target;
121
136
  }
122
- else {
123
- throw new Error("No mounted object is specified");
137
+ if (!mount) {
138
+ throw new Error(`Mount target not found: ${typeof option.target === "string" ? option.target : "invalid element"}`);
124
139
  }
125
140
  const root = createRoot(mount);
126
141
  root.render(_jsx(App, {}));
@@ -1,9 +1,9 @@
1
- /**
2
- * 检测是否支持passive事件绑定
3
- */
4
- export declare let passiveSupported: boolean;
5
1
  /**
6
2
  * 禁用和启用默认滚动
3
+ *
4
+ * 注意:现代浏览器将 document/documentElement 上的 touchmove 监听器
5
+ * 默认视为 passive: true,此时 preventDefault() 会静默失效。
6
+ * 因此必须显式声明 passive: false 才能真正阻止默认滚动行为。
7
7
  */
8
8
  export declare const defaultScroll: {
9
9
  disable(): void;
@@ -1,16 +1,3 @@
1
- /**
2
- * 检测是否支持passive事件绑定
3
- */
4
- export let passiveSupported = false;
5
- try {
6
- window.addEventListener('test', () => undefined, Object.defineProperty({}, 'passive', {
7
- get: function () {
8
- passiveSupported = true;
9
- },
10
- }));
11
- // eslint-disable-next-line no-empty
12
- }
13
- catch (err) { }
14
1
  /**
15
2
  * 触摸移动事件处理器
16
3
  */
@@ -19,12 +6,16 @@ const touchMoveHandler = (event) => {
19
6
  };
20
7
  /**
21
8
  * 禁用和启用默认滚动
9
+ *
10
+ * 注意:现代浏览器将 document/documentElement 上的 touchmove 监听器
11
+ * 默认视为 passive: true,此时 preventDefault() 会静默失效。
12
+ * 因此必须显式声明 passive: false 才能真正阻止默认滚动行为。
22
13
  */
23
14
  export const defaultScroll = {
24
15
  disable() {
25
- document.documentElement.addEventListener('touchmove', touchMoveHandler, passiveSupported ? { capture: false, passive: false } : false);
16
+ document.documentElement.addEventListener('touchmove', touchMoveHandler, { capture: false, passive: false });
26
17
  },
27
18
  enable() {
28
- document.documentElement.removeEventListener('touchmove', touchMoveHandler, passiveSupported ? { capture: false } : false);
19
+ document.documentElement.removeEventListener('touchmove', touchMoveHandler, { capture: false });
29
20
  },
30
21
  };
@@ -20,15 +20,12 @@ export function createPortalDOM(point) {
20
20
  root.render(component);
21
21
  },
22
22
  unmount() {
23
- root.unmount();
24
- if (container instanceof HTMLDivElement) {
25
- if (typeof container.remove === 'function') {
26
- container.remove();
27
- }
28
- else {
29
- mountPoint.removeChild(container);
30
- }
23
+ // 先从 DOM 移除容器,再卸载 React 树
24
+ // 避免 React 18+ 在已卸载的根上发出警告
25
+ if (container.parentNode) {
26
+ container.parentNode.removeChild(container);
31
27
  }
28
+ root.unmount();
32
29
  },
33
30
  };
34
31
  }
@@ -25,20 +25,23 @@ export function jsonp(url_1) {
25
25
  }
26
26
  const urlObject = new URL(url);
27
27
  urlObject.searchParams.set(callbackName, funcName);
28
+ // 清理辅助函数
29
+ const cleanup = () => {
30
+ delete window[funcName];
31
+ script.remove();
32
+ };
28
33
  // 创建全局script
29
34
  const script = document.createElement('script');
30
35
  script.src = urlObject.href;
31
36
  document.body.appendChild(script);
32
37
  script.onerror = (error) => {
38
+ cleanup();
33
39
  reject(error);
34
40
  };
35
41
  // 创建全局函数
36
42
  window[funcName] = (result) => {
43
+ cleanup();
37
44
  resolve(result);
38
- // 删除全局函数
39
- delete window[funcName];
40
- // 删除临时脚本
41
- script.remove();
42
45
  };
43
46
  });
44
47
  });
@@ -210,42 +210,32 @@ export function sendRequest(option) {
210
210
  return __awaiter(this, void 0, void 0, function* () {
211
211
  const { url, fetchOption, timeout } = parseRequestOption(option);
212
212
  const controller = new AbortController();
213
- return Promise.race([
214
- // 网络请求
215
- fetch(url, Object.assign(Object.assign({}, fetchOption), { signal: controller.signal }))
216
- .then((response) => {
217
- return response.json();
218
- })
219
- .then((result) => {
220
- return result;
221
- })
222
- .catch((error) => {
223
- // 如果是主动取消的请求,返回超时错误
224
- if (error.name === 'AbortError') {
225
- const result = {
226
- code: -10001,
227
- message: "Network request timeout",
228
- };
229
- return result;
230
- }
213
+ // 超时定时器,请求完成后清除以防泄漏
214
+ const timeoutId = window.setTimeout(() => {
215
+ controller.abort();
216
+ }, timeout !== null && timeout !== void 0 ? timeout : 30000);
217
+ try {
218
+ const response = yield fetch(url, Object.assign(Object.assign({}, fetchOption), { signal: controller.signal }));
219
+ const result = yield response.json();
220
+ return result;
221
+ }
222
+ catch (error) {
223
+ if (error.name === 'AbortError') {
231
224
  const result = {
232
- code: -10000,
233
- message: "An exception occurred in the network request",
225
+ code: -10001,
226
+ message: "Network request timeout",
234
227
  };
235
228
  return result;
236
- }),
237
- // 超时逻辑
238
- new Promise((resolve) => {
239
- window.setTimeout(() => {
240
- controller.abort(); // 取消请求
241
- const result = {
242
- code: -10001,
243
- message: "Network request timeout",
244
- };
245
- resolve(result);
246
- }, timeout !== null && timeout !== void 0 ? timeout : 30000);
247
- }),
248
- ]);
229
+ }
230
+ const result = {
231
+ code: -10000,
232
+ message: "An exception occurred in the network request",
233
+ };
234
+ return result;
235
+ }
236
+ finally {
237
+ clearTimeout(timeoutId);
238
+ }
249
239
  });
250
240
  }
251
241
  /**
@@ -4,42 +4,35 @@
4
4
  * @param interval
5
5
  */
6
6
  export function tick(callback, interval) {
7
- // 执行状态,是否正在执行
8
7
  let isRunning;
9
8
  let frame;
10
9
  let frameId;
11
- // 设置了tick的间隔
12
- if (interval && typeof interval === 'number') {
10
+ // 有效的正整数间隔才走间隔分支
11
+ if (typeof interval === 'number' && interval > 0) {
13
12
  let lastTick = Date.now();
14
13
  frame = () => {
15
- if (!isRunning) {
14
+ if (!isRunning)
16
15
  return;
17
- }
18
16
  frameId = requestAnimationFrame(frame);
19
17
  const now = Date.now();
20
- // 每次间隔频率逻辑上保持一致,即使帧频不一致
21
18
  if (now - lastTick >= interval) {
22
- // 本次tick的时间为上次的时间加上频率间隔
23
- lastTick = lastTick + interval;
19
+ // 直接对齐到当前时间,避免长时间后台切回后的追赶风暴
20
+ lastTick = now;
24
21
  callback();
25
22
  }
26
23
  };
27
24
  }
28
- // 没有设置tick的间隔
29
25
  else {
26
+ // 没有设置 interval 或 interval <= 0 时,每帧执行
30
27
  frame = () => {
31
- if (!isRunning) {
28
+ if (!isRunning)
32
29
  return;
33
- }
34
30
  frameId = requestAnimationFrame(frame);
35
- // 没有设置interval时,每帧都执行
36
31
  callback();
37
32
  };
38
33
  }
39
- // 开始执行
40
34
  isRunning = true;
41
35
  frameId = requestAnimationFrame(frame);
42
- // 返回一个可以立即停止的函数
43
36
  return () => {
44
37
  isRunning = false;
45
38
  cancelAnimationFrame(frameId);
@@ -1,16 +1,16 @@
1
1
  let keyIndex = 0;
2
- let last = Date.now();
2
+ let lastTimestamp = 0;
3
3
  /**
4
4
  * 生成一个全局唯一的key
5
5
  * @returns
6
6
  */
7
7
  export function uniqKey() {
8
- keyIndex += 1;
9
- let now = Date.now();
10
- if (now !== last && keyIndex > 1e9) {
8
+ const now = Date.now();
9
+ // 时间戳变化时重置计数器
10
+ if (now !== lastTimestamp) {
11
11
  keyIndex = 0;
12
+ lastTimestamp = now;
12
13
  }
13
- const key = now.toString(36) + keyIndex.toString(36);
14
- last = now;
15
- return key;
14
+ keyIndex += 1;
15
+ return now.toString(36) + keyIndex.toString(36);
16
16
  }
@@ -31,13 +31,13 @@ export function waitUntil(condition, maxTime) {
31
31
  return new Promise((resolve) => {
32
32
  const stop = tick(() => {
33
33
  const now = Date.now();
34
- const result = condition();
35
34
  // 超时返回false
36
35
  if (now - start >= maxTime) {
37
36
  stop();
38
37
  resolve(false);
39
38
  return;
40
39
  }
40
+ const result = condition();
41
41
  // 处理结果
42
42
  const handle = (res) => {
43
43
  if (res) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clxx",
3
- "version": "2.1.4",
3
+ "version": "2.1.5",
4
4
  "description": "Basic JS library for mobile devices",
5
5
  "main": "./build/index.js",
6
6
  "module": "./build/index.js",
@@ -35,20 +35,20 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@emotion/react": "^11.14.0",
38
- "dayjs": "^1.11.18",
38
+ "dayjs": "^1.11.19",
39
39
  "history": "^5.3.0",
40
- "lodash": "^4.17.21"
40
+ "lodash": "^4.17.23"
41
41
  },
42
42
  "peerDependencies": {
43
- "react": "^19.2.0",
44
- "react-dom": "^19.2.0"
43
+ "react": "^19.2.4",
44
+ "react-dom": "^19.2.4"
45
45
  },
46
46
  "devDependencies": {
47
- "@types/lodash": "^4.17.20",
48
- "@types/react": "^19.2.2",
49
- "@types/react-dom": "^19.2.2",
50
- "csstype": "^3.1.3",
51
- "rimraf": "^6.0.1",
47
+ "@types/lodash": "^4.17.24",
48
+ "@types/react": "^19.2.14",
49
+ "@types/react-dom": "^19.2.3",
50
+ "csstype": "^3.2.3",
51
+ "rimraf": "^6.1.3",
52
52
  "typescript": "^5.9.3"
53
53
  }
54
54
  }
@@ -10,12 +10,15 @@ export default defineConfig([
10
10
  files: ['**/*.{js,jsx}'],
11
11
  extends: [
12
12
  js.configs.recommended,
13
- reactHooks.configs['recommended-latest'],
13
+ reactHooks.configs.flat['recommended-latest'],
14
14
  reactRefresh.configs.vite,
15
15
  ],
16
16
  languageOptions: {
17
17
  ecmaVersion: 2020,
18
- globals: globals.browser,
18
+ globals: {
19
+ ...globals.browser,
20
+ ...globals.node,
21
+ },
19
22
  parserOptions: {
20
23
  ecmaVersion: 'latest',
21
24
  ecmaFeatures: { jsx: true },
package/test/package.json CHANGED
@@ -10,18 +10,18 @@
10
10
  "preview": "vite preview"
11
11
  },
12
12
  "dependencies": {
13
- "react": "^19.1.1",
14
- "react-dom": "^19.1.1"
13
+ "react": "^19.2.4",
14
+ "react-dom": "^19.2.4"
15
15
  },
16
16
  "devDependencies": {
17
- "@eslint/js": "^9.36.0",
18
- "@types/react": "^19.1.16",
19
- "@types/react-dom": "^19.1.9",
20
- "@vitejs/plugin-react": "^5.0.4",
21
- "eslint": "^9.36.0",
22
- "eslint-plugin-react-hooks": "^5.2.0",
23
- "eslint-plugin-react-refresh": "^0.4.22",
24
- "globals": "^16.4.0",
25
- "vite": "^7.1.7"
17
+ "@eslint/js": "^9.39.3",
18
+ "@types/react": "^19.2.14",
19
+ "@types/react-dom": "^19.2.3",
20
+ "@vitejs/plugin-react": "^5.1.4",
21
+ "eslint": "^9.39.3",
22
+ "eslint-plugin-react-hooks": "^7.0.1",
23
+ "eslint-plugin-react-refresh": "^0.5.2",
24
+ "globals": "^17.4.0",
25
+ "vite": "^7.3.1"
26
26
  }
27
27
  }
@@ -2,6 +2,8 @@ import React from "react";
2
2
  import { Ago } from "@";
3
3
 
4
4
  export default function Index() {
5
+ const now = 1583314718595;
6
+
5
7
  return (
6
8
  <div>
7
9
  <p>2019-11-2</p>
@@ -17,7 +19,7 @@ export default function Index() {
17
19
  <Ago date="2018/07/06 12:04:36" style={{ color: "red" }} />
18
20
  <hr />
19
21
  <p>Date.now()</p>
20
- <Ago date={Date.now()} style={{ color: "red" }} />
22
+ <Ago date={now} style={{ color: "red" }} />
21
23
  <hr />
22
24
  <p>new Date("2012-07-16 12:30:06")</p>
23
25
  <Ago date={new Date("2012-07-16 12:30:06")} style={{ color: "red" }} />
@@ -7,7 +7,8 @@ createApp({
7
7
  target: "#root",
8
8
  // maxDocWidth: 10000,
9
9
  async render(pathname) {
10
- let page = await import(`./${pathname}/index.jsx`);
10
+ const module = await import(`./${pathname}/index.jsx`);
11
+ const Page = module.default;
11
12
  if (pathname === 'index') {
12
13
  return <Home />;
13
14
  }
@@ -23,7 +24,7 @@ createApp({
23
24
  </button>
24
25
  </div>
25
26
  <div className="demo">
26
- <page.default />
27
+ <Page />
27
28
  </div>
28
29
  </>
29
30
  );