one-design-next 0.0.91 → 0.0.92

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.
@@ -0,0 +1,14 @@
1
+ /**
2
+ * 读阶段:只允许读取布局(getBoundingClientRect / getComputedStyle 等)。
3
+ * 返回写阶段回调,或返回 undefined 表示无需写入。
4
+ */
5
+ type LayoutRead = () => (() => void) | void;
6
+ /**
7
+ * 把「读布局 - 改 DOM」拆成两阶段,同一帧内的所有调用先全部读、再全部写。
8
+ *
9
+ * 长列表里每个实例各自「读 - 写 - 读 - 写」时,每次写都会让布局失效,
10
+ * 下一个读又触发一次完整排版(layout thrashing)。500 项时这是一帧
11
+ * 500 次排版与一次排版的差别。
12
+ */
13
+ export declare function scheduleLayoutBatch(read: LayoutRead): void;
14
+ export {};
@@ -0,0 +1,47 @@
1
+ /**
2
+ * 读阶段:只允许读取布局(getBoundingClientRect / getComputedStyle 等)。
3
+ * 返回写阶段回调,或返回 undefined 表示无需写入。
4
+ */
5
+
6
+ var pending = [];
7
+ var rafId = 0;
8
+ function flush() {
9
+ rafId = 0;
10
+ var batch = pending;
11
+ pending = [];
12
+
13
+ // 阶段一:集中读。期间无任何写入,浏览器只需排版一次。
14
+ var writes = [];
15
+ for (var i = 0; i < batch.length; i++) {
16
+ try {
17
+ var write = batch[i]();
18
+ if (write) writes.push(write);
19
+ } catch (error) {
20
+ // eslint-disable-next-line no-console
21
+ console.error('[odn] layout batch read error:', error);
22
+ }
23
+ }
24
+
25
+ // 阶段二:集中写。布局失效只发生在全部读取之后。
26
+ for (var _i = 0; _i < writes.length; _i++) {
27
+ try {
28
+ writes[_i]();
29
+ } catch (error) {
30
+ // eslint-disable-next-line no-console
31
+ console.error('[odn] layout batch write error:', error);
32
+ }
33
+ }
34
+ }
35
+
36
+ /**
37
+ * 把「读布局 - 改 DOM」拆成两阶段,同一帧内的所有调用先全部读、再全部写。
38
+ *
39
+ * 长列表里每个实例各自「读 - 写 - 读 - 写」时,每次写都会让布局失效,
40
+ * 下一个读又触发一次完整排版(layout thrashing)。500 项时这是一帧
41
+ * 500 次排版与一次排版的差别。
42
+ */
43
+ export function scheduleLayoutBatch(read) {
44
+ pending.push(read);
45
+ if (rafId) return;
46
+ rafId = requestAnimationFrame(flush);
47
+ }
@@ -0,0 +1,11 @@
1
+ type SharedResizeCallback = (entry: ResizeObserverEntry) => void;
2
+ /**
3
+ * 共享 ResizeObserver:模块级单例观察者 + WeakMap 注册表。
4
+ * 大量组件实例共用同一个观察者,避免每个实例各 new 一个,
5
+ * 也避免浏览器每帧遍历成倍增长的观察目标。
6
+ *
7
+ * 返回取消观察的清理函数,应在 effect cleanup 中调用。
8
+ * 非浏览器环境 / 无 ResizeObserver 时安全降级为空操作。
9
+ */
10
+ export declare function observeResize(target: Element, callback: SharedResizeCallback): () => void;
11
+ export {};
@@ -0,0 +1,65 @@
1
+ import canUseDom from "./canUseDom";
2
+ var sharedRO = null;
3
+ /**
4
+ * 同一元素可能被多方观察(如行容器既被标题拟合监听、又被背景同步监听)。
5
+ * 值用 Set 而非单个回调,避免后注册者覆盖先注册者、
6
+ * 以及任一方取消观察时把另一方也一并停掉。
7
+ */
8
+ var registry = new WeakMap();
9
+ function ensureSharedRO() {
10
+ if (sharedRO) return;
11
+ sharedRO = new ResizeObserver(function (entries) {
12
+ var _loop = function _loop() {
13
+ var entry = entries[i];
14
+ var callbacks = registry.get(entry.target);
15
+ if (!callbacks) return 1; // continue
16
+ callbacks.forEach(function (cb) {
17
+ // 单个回调异常不应中断同批其他实例的派发(共享观察者的隔离保证)
18
+ try {
19
+ cb(entry);
20
+ } catch (error) {
21
+ // eslint-disable-next-line no-console
22
+ console.error('[odn] shared ResizeObserver callback error:', error);
23
+ }
24
+ });
25
+ };
26
+ for (var i = 0; i < entries.length; i++) {
27
+ if (_loop()) continue;
28
+ }
29
+ });
30
+ }
31
+
32
+ /**
33
+ * 共享 ResizeObserver:模块级单例观察者 + WeakMap 注册表。
34
+ * 大量组件实例共用同一个观察者,避免每个实例各 new 一个,
35
+ * 也避免浏览器每帧遍历成倍增长的观察目标。
36
+ *
37
+ * 返回取消观察的清理函数,应在 effect cleanup 中调用。
38
+ * 非浏览器环境 / 无 ResizeObserver 时安全降级为空操作。
39
+ */
40
+ export function observeResize(target, callback) {
41
+ if (!canUseDom() || typeof ResizeObserver === 'undefined') {
42
+ return function () {};
43
+ }
44
+ ensureSharedRO();
45
+ var callbacks = registry.get(target);
46
+ if (!callbacks) {
47
+ callbacks = new Set();
48
+ registry.set(target, callbacks);
49
+ sharedRO.observe(target);
50
+ }
51
+ callbacks.add(callback);
52
+ var stopped = false;
53
+ return function () {
54
+ if (stopped) return;
55
+ stopped = true;
56
+ var current = registry.get(target);
57
+ if (!current) return;
58
+ current.delete(callback);
59
+ // 最后一个观察方撤销时才真正停止观察
60
+ if (current.size === 0) {
61
+ registry.delete(target);
62
+ sharedRO.unobserve(target);
63
+ }
64
+ };
65
+ }
@@ -0,0 +1,48 @@
1
+ export interface FitMetrics {
2
+ /** 按字形簇切分的文本段 */
3
+ segments: string[];
4
+ /**
5
+ * prefixWidths[i] = segments[0..i] 拼接后再加省略号的宽度,严格单调递增。
6
+ * 完整文本宽度不放进来(省略号可能比末位字形宽,混入会破坏单调性)。
7
+ */
8
+ prefixWidths: number[];
9
+ /** 完整文本宽度,不含省略号 */
10
+ fullWidth: number;
11
+ }
12
+ export interface FontSpec {
13
+ /** canvas font 简写,取自 resolveFont */
14
+ font: string;
15
+ /** CSS letter-spacing 的像素值;canvas measureText 不计字间距,需手工补偿 */
16
+ letterSpacing: number;
17
+ }
18
+ /**
19
+ * 按字形簇切分。emoji、肤色修饰符、ZWJ 序列、带组合符的字母都算一个整体,
20
+ * 截断落在其中任一位置都会产生乱码,所以不能按 UTF-16 码元切。
21
+ * 无 Intl.Segmenter 时退化到码点切分,至少保证代理对不裂。
22
+ */
23
+ export declare function splitGraphemes(text: string): string[];
24
+ /**
25
+ * getComputedStyle().font 在部分浏览器下返回空串(如 font-family 走 CSS 变量),
26
+ * 此时把空串赋给 canvas 会静默 fallback 到 10px sans-serif,测量整体失真。
27
+ */
28
+ export declare function resolveFont(cs: CSSStyleDeclaration): string;
29
+ /**
30
+ * 预计算所有截断位置的宽度。用 canvas measureText,不触发 DOM 排版,
31
+ * 因此单次挂载即可算完,后续宽度变化只需在结果上二分。
32
+ */
33
+ export declare function measureFit(text: string, ellipsis: string, spec: FontSpec): FitMetrics;
34
+ export interface FitResult {
35
+ text: string;
36
+ /**
37
+ * 结果是否确实在 available 内。为 false 表示窄到连「一个字形 + 省略号」
38
+ * 都放不下,只能原样返回;调用方此时必须让 CSS text-overflow 接管截断,
39
+ * 否则文本会溢出容器。
40
+ */
41
+ accurate: boolean;
42
+ }
43
+ /**
44
+ * 按可用宽度选出应显示的文本。
45
+ * 极窄时返回原文而非单独的省略号:宽度本身可能是布局未就绪的假值,
46
+ * 裁成「…」后若行宽不再变化就永远恢复不了,交给 CSS text-overflow 兜底更稳。
47
+ */
48
+ export declare function fitToWidth(metrics: FitMetrics, available: number, text: string, ellipsis: string): FitResult;
@@ -0,0 +1,123 @@
1
+ import canUseDom from "./canUseDom";
2
+ var sharedCanvas = null;
3
+ function getCtx() {
4
+ if (!canUseDom()) return null;
5
+ if (!sharedCanvas) sharedCanvas = document.createElement('canvas');
6
+ return sharedCanvas.getContext('2d');
7
+ }
8
+
9
+ /**
10
+ * 本地 TS lib 目标尚未包含 Intl.Segmenter 的声明,这里按用到的最小面手写。
11
+ * 运行时通过 'Segmenter' in Intl 判定可用性,不可用时降级到码点切分。
12
+ */
13
+
14
+ var segmenter = function () {
15
+ if (typeof Intl === 'undefined' || !('Segmenter' in Intl)) return null;
16
+ var Ctor = Intl.Segmenter;
17
+ return new Ctor(undefined, {
18
+ granularity: 'grapheme'
19
+ });
20
+ }();
21
+
22
+ /**
23
+ * 按字形簇切分。emoji、肤色修饰符、ZWJ 序列、带组合符的字母都算一个整体,
24
+ * 截断落在其中任一位置都会产生乱码,所以不能按 UTF-16 码元切。
25
+ * 无 Intl.Segmenter 时退化到码点切分,至少保证代理对不裂。
26
+ */
27
+ export function splitGraphemes(text) {
28
+ if (!text) return [];
29
+ if (segmenter) {
30
+ return Array.from(segmenter.segment(text), function (s) {
31
+ return s.segment;
32
+ });
33
+ }
34
+ return Array.from(text);
35
+ }
36
+
37
+ /**
38
+ * getComputedStyle().font 在部分浏览器下返回空串(如 font-family 走 CSS 变量),
39
+ * 此时把空串赋给 canvas 会静默 fallback 到 10px sans-serif,测量整体失真。
40
+ */
41
+ export function resolveFont(cs) {
42
+ if (cs.font) return cs.font;
43
+ return "".concat(cs.fontStyle, " ").concat(cs.fontWeight, " ").concat(cs.fontSize, " / ").concat(cs.lineHeight, " ").concat(cs.fontFamily);
44
+ }
45
+
46
+ /**
47
+ * 预计算所有截断位置的宽度。用 canvas measureText,不触发 DOM 排版,
48
+ * 因此单次挂载即可算完,后续宽度变化只需在结果上二分。
49
+ */
50
+ export function measureFit(text, ellipsis, spec) {
51
+ var segments = splitGraphemes(text);
52
+ if (!segments.length) {
53
+ return {
54
+ segments: segments,
55
+ prefixWidths: [],
56
+ fullWidth: 0
57
+ };
58
+ }
59
+ var ctx = getCtx();
60
+ if (!ctx) {
61
+ // 拿不到 canvas 时给出「永远放不下」的度量,调用方据此保留原文
62
+ return {
63
+ segments: segments,
64
+ prefixWidths: [],
65
+ fullWidth: Number.POSITIVE_INFINITY
66
+ };
67
+ }
68
+ ctx.font = spec.font;
69
+ var letterSpacing = spec.letterSpacing;
70
+ var ellipsisWidth = ctx.measureText(ellipsis).width + letterSpacing * splitGraphemes(ellipsis).length;
71
+ var prefixWidths = [];
72
+ var prefix = '';
73
+ for (var i = 0; i < segments.length - 1; i++) {
74
+ prefix += segments[i];
75
+ prefixWidths.push(ctx.measureText(prefix).width + letterSpacing * (i + 1) + ellipsisWidth);
76
+ }
77
+ return {
78
+ segments: segments,
79
+ prefixWidths: prefixWidths,
80
+ fullWidth: ctx.measureText(text).width + letterSpacing * segments.length
81
+ };
82
+ }
83
+
84
+ /** 最后一个 <= available 的下标,-1 表示一个字形加省略号都放不下 */
85
+ function bisect(widths, available) {
86
+ var lo = 0;
87
+ var hi = widths.length - 1;
88
+ var best = -1;
89
+ while (lo <= hi) {
90
+ var mid = lo + hi >> 1;
91
+ if (widths[mid] <= available) {
92
+ best = mid;
93
+ lo = mid + 1;
94
+ } else {
95
+ hi = mid - 1;
96
+ }
97
+ }
98
+ return best;
99
+ }
100
+ /**
101
+ * 按可用宽度选出应显示的文本。
102
+ * 极窄时返回原文而非单独的省略号:宽度本身可能是布局未就绪的假值,
103
+ * 裁成「…」后若行宽不再变化就永远恢复不了,交给 CSS text-overflow 兜底更稳。
104
+ */
105
+ export function fitToWidth(metrics, available, text, ellipsis) {
106
+ if (!metrics.segments.length) return {
107
+ text: text,
108
+ accurate: true
109
+ };
110
+ if (metrics.fullWidth <= available) return {
111
+ text: text,
112
+ accurate: true
113
+ };
114
+ var best = bisect(metrics.prefixWidths, available);
115
+ if (best < 0) return {
116
+ text: text,
117
+ accurate: false
118
+ };
119
+ return {
120
+ text: metrics.segments.slice(0, best + 1).join('') + ellipsis,
121
+ accurate: true
122
+ };
123
+ }
@@ -38,9 +38,13 @@ export interface ChatItemProps {
38
38
  defaultDeleteOpen?: boolean;
39
39
  onDeleteOpenChange?: (open: boolean) => void;
40
40
  }
41
- export declare function ChatItem({ id, title, active, type, status, pinned, time, onClick, onRename, onPin, onDelete, actionMenuZIndex, renaming: renamingProp, defaultRenaming, onRenamingChange, menuOpen: menuOpenProp, defaultMenuOpen, onMenuOpenChange, deleteOpen: deleteOpenProp, defaultDeleteOpen, onDeleteOpenChange, }: ChatItemProps): import("react").JSX.Element;
42
- export declare namespace ChatItem {
43
- var displayName: string;
44
- }
41
+ declare function ChatItemInner({ id, title, active, type, status, pinned, time, onClick, onRename, onPin, onDelete, actionMenuZIndex, renaming: renamingProp, defaultRenaming, onRenamingChange, menuOpen: menuOpenProp, defaultMenuOpen, onMenuOpenChange, deleteOpen: deleteOpenProp, defaultDeleteOpen, onDeleteOpenChange, }: ChatItemProps): import("react").JSX.Element;
42
+ /**
43
+ * 会话列表项。
44
+ *
45
+ * memo:列表里单项 hover / 选中会触发父级重渲染,未 memo 时其余项会一并
46
+ * 重跑 render 与相对时间计算。父级需传入稳定的回调引用才能生效。
47
+ */
48
+ export declare const ChatItem: import("react").MemoExoticComponent<typeof ChatItemInner>;
45
49
  export default ChatItem;
46
50
  export { formatRelativeTime } from './relative-time';
@@ -1,6 +1,9 @@
1
1
  import _toConsumableArray from "@babel/runtime/helpers/esm/toConsumableArray";
2
2
  import _slicedToArray from "@babel/runtime/helpers/esm/slicedToArray";
3
- import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
3
+ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
4
+ import { scheduleLayoutBatch } from "../_util/layoutBatch";
5
+ import { observeResize } from "../_util/sharedResizeObserver";
6
+ import { fitToWidth, measureFit, resolveFont } from "../_util/textFit";
4
7
  import Dialog from "../dialog";
5
8
  import Dropdown from "../dropdown";
6
9
  import HoverFill from "../hover-fill";
@@ -15,6 +18,12 @@ var STATUS_ARIA_LABEL = {
15
18
  };
16
19
  var ELLIPSIS = '…';
17
20
 
21
+ /**
22
+ * 标记「JS 已把标题裁到可用宽度内」,由 useFittedTitle 在写入时同步,
23
+ * 不由 React 渲染:它表达的是某一帧的测量结果,而非「是否启用拟合」。
24
+ */
25
+ var TITLE_FITTED_ATTR = 'data-odn-chat-item-title-fitted';
26
+
18
27
  /** 可用宽度变化小于该值时跳过重算(与二分容差同量级) */
19
28
  var FIT_WIDTH_EPS = 0.5;
20
29
  /**
@@ -24,65 +33,85 @@ var FIT_WIDTH_EPS = 0.5;
24
33
  var FIT_MIN_RELIABLE_WIDTH = 24;
25
34
 
26
35
  /**
27
- * 有 trailing(客服 icon / status / 时间)时,用 DOM 实测把标题裁到可用宽度。
36
+ * 有 trailing(客服 icon / status / 时间)时,把标题裁到可用宽度。
28
37
  * 解决 CSS text-overflow 按字符边界截断后,省略号右侧留白把 trailing 顶开的问题。
29
38
  *
30
- * 性能:RO 监听行 / label / trailing(active 时时间让位不改变行宽,必须听内部);
31
- * 回调经 rAF 合并;缓存 maxWidth / trailingWidth / 裁切结果。
39
+ * 测量走 textFit Canvas 预算:挂载时一次算完所有截断位置的宽度,
40
+ * 之后每次宽度变化只做二分,不再「填字 - 排版 - 量宽」地反复试探。
41
+ *
42
+ * 裁切结果直接写进 titleEl,不走 state:长列表拖宽时零 re-render。
43
+ * 标题本身由 React 渲染,title 变化时 React 会把完整标题写回 DOM,
44
+ * 随后本 effect 重跑重新裁切,顺序天然正确。
45
+ *
46
+ * 性能:RO 只监听整行;回调经 rAF 合并;缓存 maxWidth / trailingWidth / 裁切结果。
47
+ * trailing 宽度变化(时间文案 / 状态 / 让位)由调用方经 layoutKey 触发重测。
32
48
  */
33
49
  function useFittedTitle(title, enabled, labelRef, titleRef, /** 右对齐区(状态 / 时间),量宽预留 */
34
50
  trailingRef, /** 紧跟标题的类型 icon,量宽预留 */
35
- typeIconRef, /** active 时时间让位 / 字重变化,需强制重测 */
51
+ typeIconRef, /** trailing 宽度 / 字重变化,需强制重测 */
36
52
  layoutKey) {
37
- var _useState = useState(title),
38
- _useState2 = _slicedToArray(_useState, 2),
39
- fitted = _useState2[0],
40
- setFitted = _useState2[1];
41
53
  useLayoutEffect(function () {
42
- var _document$fonts, _label$closest;
54
+ var _measure, _document$fonts, _label$closest;
55
+ var titleEl = titleRef.current;
56
+ if (!titleEl) return;
57
+
58
+ // React 渲染的是完整标题;未启用拟合时保持原样,交给 CSS text-overflow
43
59
  if (!enabled) {
44
- setFitted(title);
60
+ titleEl.textContent = title;
61
+ titleEl.removeAttribute(TITLE_FITTED_ATTR);
45
62
  return;
46
63
  }
47
64
  var label = labelRef.current;
48
- var titleEl = titleRef.current;
49
- if (!label || !titleEl) return;
65
+ if (!label) return;
50
66
 
51
- // 标题变更时先回到原文,避免沿用上一会话裁成的「…」
52
- setFitted(title);
67
+ // 先回到原文,避免沿用上一会话裁成的「…」,也保证按完整标题取字体
68
+ titleEl.textContent = title;
69
+ var titleCs = getComputedStyle(titleEl);
70
+ var metrics = measureFit(title, ELLIPSIS, {
71
+ font: resolveFont(titleCs),
72
+ letterSpacing: Number.parseFloat(titleCs.letterSpacing) || 0
73
+ });
53
74
  var cancelled = false;
54
- var rafId = 0;
75
+ var scheduled = false;
55
76
  var lastMaxWidth = Number.NaN;
56
77
  var lastTrailingWidth = Number.NaN;
57
78
  var lastFitted = title;
58
- var probe = document.createElement('span');
59
- probe.setAttribute('aria-hidden', 'true');
60
- probe.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none;white-space:nowrap;left:-9999px;top:0;';
61
- var syncProbeFont = function syncProbeFont() {
62
- var cs = getComputedStyle(titleEl);
63
- probe.style.fontFamily = cs.fontFamily;
64
- probe.style.fontSize = cs.fontSize;
65
- probe.style.fontWeight = cs.fontWeight;
66
- probe.style.fontStyle = cs.fontStyle;
67
- probe.style.letterSpacing = cs.letterSpacing;
68
- probe.style.fontFeatureSettings = cs.fontFeatureSettings;
69
- probe.style.fontVariationSettings = cs.fontVariationSettings;
79
+ var lastFitAccurate = false;
80
+
81
+ /**
82
+ * accurate 表示「当前文本确实在可用宽度内」,此时才挂 fitted 标记让
83
+ * CSS 停止截断(否则省略号右侧会留出字符边界的空白,把 trailing 顶开)。
84
+ * 放弃裁切时必须摘掉标记,把截断交回 CSS,否则标题直接溢出行外。
85
+ */
86
+ var applyFit = function applyFit(text, accurate) {
87
+ titleEl.textContent = text;
88
+ if (accurate) titleEl.setAttribute(TITLE_FITTED_ATTR, '');else titleEl.removeAttribute(TITLE_FITTED_ATTR);
70
89
  };
71
- var measure = function measure(text) {
72
- probe.textContent = text;
73
- return probe.getBoundingClientRect().width;
90
+
91
+ /**
92
+ * gap 与 trailing / typeIcon 的宽度在同一个 effect 周期内是常量:
93
+ * 所有会改变它们的条件(选中、菜单开合、时间文案、状态、类型 icon)
94
+ * 都在 layoutKey 里,变化时 effect 会整体重跑。
95
+ * 拖拽时因此只需读行内标题区的宽度,省掉每帧每项的 getComputedStyle。
96
+ */
97
+ var gap = Number.NaN;
98
+ var trailingWidth = 0;
99
+ var typeWidth = 0;
100
+ var readStatics = function readStatics() {
101
+ var _trailingRef$current$, _trailingRef$current, _typeIconRef$current$, _typeIconRef$current;
102
+ var cs = getComputedStyle(label);
103
+ gap = Number.parseFloat(cs.columnGap || cs.gap) || 0;
104
+ trailingWidth = (_trailingRef$current$ = (_trailingRef$current = trailingRef.current) === null || _trailingRef$current === void 0 ? void 0 : _trailingRef$current.getBoundingClientRect().width) !== null && _trailingRef$current$ !== void 0 ? _trailingRef$current$ : 0;
105
+ typeWidth = (_typeIconRef$current$ = (_typeIconRef$current = typeIconRef.current) === null || _typeIconRef$current === void 0 ? void 0 : _typeIconRef$current.getBoundingClientRect().width) !== null && _typeIconRef$current$ !== void 0 ? _typeIconRef$current$ : 0;
74
106
  };
75
- var fit = function fit() {
76
- var _trailing$getBounding, _typeIcon$getBounding;
107
+
108
+ /**
109
+ * 只读布局,返回写入回调交由批处理统一执行。
110
+ * 这里不能直接改 DOM:同帧内还有其他实例要读,写入会让它们的读重新排版。
111
+ */
112
+ var measure = function measure() {
77
113
  if (cancelled) return;
78
- syncProbeFont();
79
- if (!probe.isConnected) document.body.appendChild(probe);
80
- var cs = getComputedStyle(label);
81
- var gap = Number.parseFloat(cs.columnGap || cs.gap) || 0;
82
- var trailing = trailingRef.current;
83
- var typeIcon = typeIconRef.current;
84
- var trailingWidth = (_trailing$getBounding = trailing === null || trailing === void 0 ? void 0 : trailing.getBoundingClientRect().width) !== null && _trailing$getBounding !== void 0 ? _trailing$getBounding : 0;
85
- var typeWidth = (_typeIcon$getBounding = typeIcon === null || typeIcon === void 0 ? void 0 : typeIcon.getBoundingClientRect().width) !== null && _typeIcon$getBounding !== void 0 ? _typeIcon$getBounding : 0;
114
+ if (!Number.isFinite(gap)) readStatics();
86
115
  // 标题两侧可能各有一段 gap:title↔type、type/title↔trailing
87
116
  var gapCount = (typeWidth > 0 ? 1 : 0) + (trailingWidth > 0 ? 1 : 0);
88
117
  var maxWidth = Math.max(0, label.getBoundingClientRect().width - trailingWidth - typeWidth - gap * gapCount);
@@ -94,71 +123,58 @@ layoutKey) {
94
123
  if (maxWidth < FIT_MIN_RELIABLE_WIDTH) {
95
124
  lastMaxWidth = Number.NaN;
96
125
  lastTrailingWidth = Number.NaN;
97
- if (lastFitted !== title) {
98
- lastFitted = title;
99
- setFitted(title);
100
- }
101
- return;
126
+ // 此刻 trailing / typeIcon 多半也没量准,下次重读
127
+ gap = Number.NaN;
128
+ if (lastFitted === title && !lastFitAccurate) return;
129
+ lastFitted = title;
130
+ lastFitAccurate = false;
131
+ return function () {
132
+ return applyFit(title, false);
133
+ };
102
134
  }
103
135
  lastMaxWidth = maxWidth;
104
136
  lastTrailingWidth = trailingWidth;
105
- var next = title;
106
- if (measure(title) > maxWidth + FIT_WIDTH_EPS) {
107
- var lo = 0;
108
- var hi = title.length;
109
- next = title;
110
- while (lo <= hi) {
111
- var mid = lo + hi >> 1;
112
- var candidate = "".concat(title.slice(0, mid)).concat(ELLIPSIS);
113
- if (measure(candidate) <= maxWidth + FIT_WIDTH_EPS) {
114
- next = candidate;
115
- lo = mid + 1;
116
- } else {
117
- hi = mid - 1;
118
- }
119
- }
120
- // 极端窄宽仍放不下「字+…」时交给 CSS ellipsis,保留原文
121
- if (next === ELLIPSIS || next === title && measure(title) > maxWidth + FIT_WIDTH_EPS) {
122
- var withEllipsis = "".concat(title.slice(0, 1)).concat(ELLIPSIS);
123
- next = measure(withEllipsis) <= maxWidth + FIT_WIDTH_EPS ? withEllipsis : title;
124
- }
125
- }
126
- if (next === lastFitted) return;
137
+ var _fitToWidth = fitToWidth(metrics, maxWidth + FIT_WIDTH_EPS, title, ELLIPSIS),
138
+ next = _fitToWidth.text,
139
+ accurate = _fitToWidth.accurate;
140
+ if (next === lastFitted && accurate === lastFitAccurate) return;
127
141
  lastFitted = next;
128
- setFitted(next);
142
+ lastFitAccurate = accurate;
143
+ return function () {
144
+ return applyFit(next, accurate);
145
+ };
129
146
  };
130
147
  var scheduleFit = function scheduleFit() {
131
- if (cancelled || rafId) return;
132
- rafId = window.requestAnimationFrame(function () {
133
- rafId = 0;
134
- fit();
148
+ if (cancelled || scheduled) return;
149
+ scheduled = true;
150
+ scheduleLayoutBatch(function () {
151
+ scheduled = false;
152
+ return measure();
135
153
  });
136
154
  };
137
155
 
138
- // 首屏同步裁切,避免首帧闪全标题;后续宽度变化走 rAF 合并
139
- fit();
156
+ // 首屏同步裁切,避免首帧闪全标题;后续宽度变化走批处理合并
157
+ (_measure = measure()) === null || _measure === void 0 || _measure();
140
158
  void ((_document$fonts = document.fonts) === null || _document$fonts === void 0 ? void 0 : _document$fonts.ready.then(function () {
141
159
  if (cancelled) return;
142
- // 字体就绪后字形宽度可能变,需绕过 maxWidth 缓存强制重测
160
+ // 字体就绪后字形宽度可能变,需重新预算并绕过 maxWidth 缓存
161
+ var readyCs = getComputedStyle(titleEl);
162
+ metrics = measureFit(title, ELLIPSIS, {
163
+ font: resolveFont(readyCs),
164
+ letterSpacing: Number.parseFloat(readyCs.letterSpacing) || 0
165
+ });
143
166
  lastMaxWidth = Number.NaN;
167
+ gap = Number.NaN;
144
168
  scheduleFit();
145
169
  }));
146
170
  var row = (_label$closest = label.closest('[data-odn-chat-item-btn]')) !== null && _label$closest !== void 0 ? _label$closest : label;
147
- var ro = new ResizeObserver(scheduleFit);
148
- ro.observe(row);
149
- ro.observe(label);
150
- var trailing = trailingRef.current;
151
- if (trailing) ro.observe(trailing);
152
- var typeIcon = typeIconRef.current;
153
- if (typeIcon) ro.observe(typeIcon);
171
+ var stopObserving = observeResize(row, scheduleFit);
154
172
  return function () {
173
+ // 批处理队列里可能仍排着本实例的读回调,靠 cancelled 短路即可
155
174
  cancelled = true;
156
- if (rafId) window.cancelAnimationFrame(rafId);
157
- ro.disconnect();
158
- probe.remove();
175
+ stopObserving();
159
176
  };
160
177
  }, [title, enabled, layoutKey, labelRef, titleRef, trailingRef, typeIconRef]);
161
- return enabled ? fitted : title;
162
178
  }
163
179
  function ChatItemStatus(_ref) {
164
180
  var status = _ref.status;
@@ -204,18 +220,28 @@ function ChatItemStatus(_ref) {
204
220
  }));
205
221
  }
206
222
  function useControllableState(controlled, defaultValue, onChange) {
207
- var _useState3 = useState(defaultValue),
208
- _useState4 = _slicedToArray(_useState3, 2),
209
- internal = _useState4[0],
210
- setInternal = _useState4[1];
223
+ var _useState = useState(defaultValue),
224
+ _useState2 = _slicedToArray(_useState, 2),
225
+ internal = _useState2[0],
226
+ setInternal = _useState2[1];
211
227
  var value = controlled !== null && controlled !== void 0 ? controlled : internal;
212
- var setValue = function setValue(next) {
213
- onChange === null || onChange === void 0 || onChange(next);
214
- if (controlled === undefined) setInternal(next);
228
+ // 每次渲染新建 setValue 会让下游 memo 组件(Dropdown / 菜单项)全部失效
229
+ var latest = useRef({
230
+ controlled: controlled,
231
+ onChange: onChange
232
+ });
233
+ latest.current = {
234
+ controlled: controlled,
235
+ onChange: onChange
215
236
  };
237
+ var setValue = useCallback(function (next) {
238
+ var _latest$current$onCha, _latest$current;
239
+ (_latest$current$onCha = (_latest$current = latest.current).onChange) === null || _latest$current$onCha === void 0 || _latest$current$onCha.call(_latest$current, next);
240
+ if (latest.current.controlled === undefined) setInternal(next);
241
+ }, []);
216
242
  return [value, setValue];
217
243
  }
218
- export function ChatItem(_ref2) {
244
+ function ChatItemInner(_ref2) {
219
245
  var id = _ref2.id,
220
246
  title = _ref2.title,
221
247
  active = _ref2.active,
@@ -246,10 +272,10 @@ export function ChatItem(_ref2) {
246
272
  _useControllableState2 = _slicedToArray(_useControllableState, 2),
247
273
  renaming = _useControllableState2[0],
248
274
  setRenaming = _useControllableState2[1];
249
- var _useState5 = useState(title),
250
- _useState6 = _slicedToArray(_useState5, 2),
251
- draft = _useState6[0],
252
- setDraft = _useState6[1];
275
+ var _useState3 = useState(title),
276
+ _useState4 = _slicedToArray(_useState3, 2),
277
+ draft = _useState4[0],
278
+ setDraft = _useState4[1];
253
279
  var _useControllableState3 = useControllableState(deleteOpenProp, defaultDeleteOpen, onDeleteOpenChange),
254
280
  _useControllableState4 = _slicedToArray(_useControllableState3, 2),
255
281
  deleteOpen = _useControllableState4[0],
@@ -258,12 +284,12 @@ export function ChatItem(_ref2) {
258
284
  _useControllableState6 = _slicedToArray(_useControllableState5, 2),
259
285
  menuOpen = _useControllableState6[0],
260
286
  setMenuOpen = _useControllableState6[1];
261
- var _useState7 = useState(function () {
287
+ var _useState5 = useState(function () {
262
288
  return Date.now();
263
289
  }),
264
- _useState8 = _slicedToArray(_useState7, 2),
265
- now = _useState8[0],
266
- setNow = _useState8[1];
290
+ _useState6 = _slicedToArray(_useState5, 2),
291
+ now = _useState6[0],
292
+ setNow = _useState6[1];
267
293
  var inputRef = useRef(null);
268
294
  var labelRef = useRef(null);
269
295
  var titleRef = useRef(null);
@@ -278,8 +304,9 @@ export function ChatItem(_ref2) {
278
304
  var showTime = Boolean(timeLabel);
279
305
  var showTrailing = showStatus || showTime;
280
306
  var fitTitle = showTypeIcon || showTrailing;
281
- var displayTitle = useFittedTitle(title, fitTitle, labelRef, titleRef, trailingRef, typeIconRef, // active / 菜单开合都会改时间让位与字重,行宽可能不变
282
- "".concat(active ? 1 : 0, ":").concat(menuOpen ? 1 : 0));
307
+ useFittedTitle(title, fitTitle, labelRef, titleRef, trailingRef, typeIconRef, // trailing / 字重的全部变化来源。这些都会改标题可用宽度而行宽不变,
308
+ // 提前声明即可,无需为此额外监听 label / trailing / typeIcon 的尺寸。
309
+ "".concat(active ? 1 : 0, ":").concat(menuOpen ? 1 : 0, ":").concat(timeLabel, ":").concat(status !== null && status !== void 0 ? status : '', ":").concat(showTypeIcon ? 1 : 0));
283
310
  useEffect(function () {
284
311
  if (!renaming) setDraft(title);
285
312
  }, [title, renaming]);
@@ -316,19 +343,21 @@ export function ChatItem(_ref2) {
316
343
  setDraft(title);
317
344
  setRenaming(false);
318
345
  };
319
- var actionMenu = [].concat(_toConsumableArray(onRename ? [{
320
- value: 'rename',
321
- label: '修改名称',
322
- icon: 'pencil-line'
323
- }] : []), _toConsumableArray(onPin ? [{
324
- value: 'pin',
325
- label: pinned ? '取消置顶' : '置顶',
326
- icon: 'arrow-up-to-line'
327
- }] : []), _toConsumableArray(onDelete ? [{
328
- value: 'delete',
329
- label: '删除',
330
- icon: 'trash-2'
331
- }] : []));
346
+ var actionMenu = useMemo(function () {
347
+ return [].concat(_toConsumableArray(onRename ? [{
348
+ value: 'rename',
349
+ label: '修改名称',
350
+ icon: 'pencil-line'
351
+ }] : []), _toConsumableArray(onPin ? [{
352
+ value: 'pin',
353
+ label: pinned ? '取消置顶' : '置顶',
354
+ icon: 'arrow-up-to-line'
355
+ }] : []), _toConsumableArray(onDelete ? [{
356
+ value: 'delete',
357
+ label: '删除',
358
+ icon: 'trash-2'
359
+ }] : []));
360
+ }, [onRename, onPin, onDelete, pinned]);
332
361
  return /*#__PURE__*/React.createElement(HoverFill, {
333
362
  "data-odn-chat-item": true,
334
363
  "data-odn-chat-item-id": id,
@@ -374,9 +403,8 @@ export function ChatItem(_ref2) {
374
403
  ref: titleRef,
375
404
  "data-odn-chat-item-title": true,
376
405
  "data-odn-chat-item-title-bold": active ? '' : undefined,
377
- "data-odn-chat-item-title-fitted": fitTitle ? '' : undefined,
378
406
  title: title
379
- }, displayTitle), showTypeIcon ? /*#__PURE__*/React.createElement("span", {
407
+ }, title), showTypeIcon ? /*#__PURE__*/React.createElement("span", {
380
408
  ref: typeIconRef,
381
409
  "data-odn-chat-item-type-icon": true,
382
410
  "aria-label": "\u4EBA\u5DE5\u5BA2\u670D\u4F1A\u8BDD",
@@ -446,6 +474,14 @@ export function ChatItem(_ref2) {
446
474
  }
447
475
  })));
448
476
  }
477
+
478
+ /**
479
+ * 会话列表项。
480
+ *
481
+ * memo:列表里单项 hover / 选中会触发父级重渲染,未 memo 时其余项会一并
482
+ * 重跑 render 与相对时间计算。父级需传入稳定的回调引用才能生效。
483
+ */
484
+ export var ChatItem = /*#__PURE__*/memo(ChatItemInner);
449
485
  ChatItem.displayName = 'ChatItem';
450
486
  export default ChatItem;
451
487
  export { formatRelativeTime } from "./relative-time";
@@ -6,6 +6,7 @@ import _objectSpread from "@babel/runtime/helpers/esm/objectSpread2";
6
6
  import _objectWithoutProperties from "@babel/runtime/helpers/esm/objectWithoutProperties";
7
7
  var _excluded = ["bgClassName", "bgStyle", "children", "onMouseEnter", "onMouseLeave", "onMouseDown", "hoverColor", "activeColor", "disabled", "hoverCursor"];
8
8
  import React, { forwardRef, useCallback, useContext, useEffect, useRef } from 'react';
9
+ import { observeResize } from "../_util/sharedResizeObserver";
9
10
  import { ConfigContext } from "../config-provider";
10
11
  import "./style";
11
12
  var DEFAULT_HOVER_COLOR = 'rgba(33, 34, 38, 0.05)';
@@ -66,8 +67,6 @@ var HoverFill = /*#__PURE__*/forwardRef(function (_ref, outerRef) {
66
67
  y = _root$getBoundingClie.y;
67
68
  if (window.odnHoverFillState && window.odnHoverFillState.bgVisible && window.odnHoverFillState.bgNode && window.odnHoverFillState.baseNode && window.odnHoverFillState.baseNode !== root && ((_window$odnHoverFillS3 = window.odnHoverFillState.baseNode) === null || _window$odnHoverFillS3 === void 0 ? void 0 : _window$odnHoverFillS3.parentNode) === root.parentNode && hoverFillConfig.sharable) {
68
69
  var _window$odnHoverFillS4 = window.odnHoverFillState,
69
- bgX = _window$odnHoverFillS4.bgX,
70
- bgY = _window$odnHoverFillS4.bgY,
71
70
  bgScale = _window$odnHoverFillS4.bgScale,
72
71
  baseNode = _window$odnHoverFillS4.baseNode;
73
72
  var _baseNode$getBounding = baseNode.getBoundingClientRect(),
@@ -205,6 +204,12 @@ var HoverFill = /*#__PURE__*/forwardRef(function (_ref, outerRef) {
205
204
  }, 0);
206
205
  }, 100);
207
206
  };
207
+ var handleMouseUp = function handleMouseUp() {
208
+ window.removeEventListener('mouseup', handleMouseUp, false);
209
+ if (window.odnHoverFillState.bgNode) {
210
+ window.odnHoverFillState.bgNode.style.background = window.odnHoverFillState.bgVisible ? hoverColor : 'transparent';
211
+ }
212
+ };
208
213
  var handleMouseDown = function handleMouseDown(e) {
209
214
  e.persist();
210
215
  if (disabled) {
@@ -218,12 +223,6 @@ var HoverFill = /*#__PURE__*/forwardRef(function (_ref, outerRef) {
218
223
  }
219
224
  window.addEventListener('mouseup', handleMouseUp, false);
220
225
  };
221
- var handleMouseUp = function handleMouseUp() {
222
- window.removeEventListener('mouseup', handleMouseUp, false);
223
- if (window.odnHoverFillState.bgNode) {
224
- window.odnHoverFillState.bgNode.style.background = window.odnHoverFillState.bgVisible ? hoverColor : 'transparent';
225
- }
226
- };
227
226
  useEffect(function () {
228
227
  if (!window.odnHoverFillState) {
229
228
  window.odnHoverFillState = {
@@ -245,16 +244,10 @@ var HoverFill = /*#__PURE__*/forwardRef(function (_ref, outerRef) {
245
244
  }, []);
246
245
  useEffect(function () {
247
246
  var root = ref.current;
248
- if (!root || typeof ResizeObserver === 'undefined') {
247
+ if (!root) {
249
248
  return;
250
249
  }
251
- var observer = new ResizeObserver(function () {
252
- syncBgSize();
253
- });
254
- observer.observe(root);
255
- return function () {
256
- observer.disconnect();
257
- };
250
+ return observeResize(root, syncBgSize);
258
251
  }, [ref, syncBgSize]);
259
252
  return /*#__PURE__*/React.createElement("div", _extends({
260
253
  ref: ref,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "one-design-next",
3
- "version": "0.0.91",
3
+ "version": "0.0.92",
4
4
  "description": "One Design Next from TAD@tencent.com",
5
5
  "license": "MIT",
6
6
  "type": "module",