dsh-codinput 0.3.8 → 0.3.9
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/CHANGELOG.md +19 -0
- package/NOTICE +1 -1
- package/README.en.md +5 -4
- package/README.md +5 -4
- package/TESTING.md +12 -5
- package/lib/client.js +22 -22
- package/package.json +1 -1
- package/src/client/components/MenuSurface.tsx +64 -12
- package/src/client/components/ModelMenu.tsx +5 -17
- package/src/client/components/PermissionMenu.tsx +8 -3
- package/src/client/settings.tsx +8 -3
package/package.json
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* 更长的子页(模型/推理强度列表)时卡片保持小菜单的 top 向下生长,
|
|
10
10
|
* 表现为「向下弹出」且列表伸出视口外够不着。
|
|
11
11
|
*
|
|
12
|
+
* 锚点按元素实时读取:早期实现把 getBoundingClientRect 快照塞进 state,
|
|
13
|
+
* 重算时读同一份快照,scroll/resize 监听等于空转——布局一变菜单就停在打开
|
|
14
|
+
* 瞬间的位置(表现为「菜单飘到屏幕角落」)。现在每次 place 都读实时 rect,
|
|
15
|
+
* 并在打开期间逐帧比对锚点位置,兜住「不触发 scroll/resize 的重排」。
|
|
16
|
+
*
|
|
12
17
|
* 滚动复刻官方两级结构:卡片 overflow:hidden +
|
|
13
18
|
* max-height: min(360px, 100vh - 96px),列表在 .dci-viewport 内滚,
|
|
14
19
|
* 细滚动条由卡片注入的 --dsh-scrollbar-thumb(l2) 变量着色(官方
|
|
@@ -16,10 +21,17 @@
|
|
|
16
21
|
*/
|
|
17
22
|
|
|
18
23
|
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
|
24
|
+
import { createPortal } from 'react-dom';
|
|
19
25
|
|
|
20
26
|
export interface MenuSurfaceProps {
|
|
21
|
-
/**
|
|
22
|
-
anchor:
|
|
27
|
+
/** 触发器元素:每次重算都读实时 rect,锚点移动后菜单跟随。 */
|
|
28
|
+
anchor: HTMLElement | null;
|
|
29
|
+
/**
|
|
30
|
+
* 打开瞬间的锚点矩形。点击本身可能触发布局变化(输入框失焦、卡片换型),
|
|
31
|
+
* layout effect 时读到的 rect 已不是用户点下去时看到的那个——首次定位优先
|
|
32
|
+
* 用它,保证菜单位置与点击时的按钮重合。
|
|
33
|
+
*/
|
|
34
|
+
initialRect?: { left: number; top: number; right: number; bottom: number } | null;
|
|
23
35
|
onClose(): void;
|
|
24
36
|
children: ReactNode;
|
|
25
37
|
/** 右对齐锚点(trailing 控件用)。 */
|
|
@@ -32,23 +44,32 @@ export interface MenuSurfaceProps {
|
|
|
32
44
|
/** 官方 place() 的视口留距。 */
|
|
33
45
|
const MARGIN = 12;
|
|
34
46
|
|
|
47
|
+
/** 锚点位移阈值(px):小于它视为亚像素抖动,不重排菜单。 */
|
|
48
|
+
const SHIFT_THRESHOLD = 1;
|
|
49
|
+
|
|
35
50
|
export function MenuSurface(props: MenuSurfaceProps): JSX.Element {
|
|
36
|
-
const { anchor, onClose, children, alignEnd, minWidth = 218, side = 'top' } = props;
|
|
51
|
+
const { anchor, initialRect, onClose, children, alignEnd, minWidth = 218, side = 'top' } = props;
|
|
52
|
+
const firstPlaceRef = useRef(true);
|
|
37
53
|
const ref = useRef<HTMLDivElement | null>(null);
|
|
38
54
|
const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
|
|
39
55
|
|
|
40
56
|
useLayoutEffect(() => {
|
|
41
57
|
const el = ref.current;
|
|
42
|
-
if (!el) return;
|
|
58
|
+
if (!el || !anchor) return;
|
|
43
59
|
const place = (): void => {
|
|
44
60
|
// 尺寸取 offset*(官方同款):不受自身 top/left 变更反馈影响。
|
|
45
61
|
const w = el.offsetWidth;
|
|
46
62
|
const h = el.offsetHeight;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (
|
|
51
|
-
|
|
63
|
+
// 锚点尚未布局(或被隐藏)时不落位:宁可保持隐藏,也不写进一组
|
|
64
|
+
// 没有视口约束的坐标(否则菜单会停在视口外,看起来「飘走」)。
|
|
65
|
+
const rect = (firstPlaceRef.current && initialRect) || anchor.getBoundingClientRect();
|
|
66
|
+
if (rect.right - rect.left === 0 || rect.bottom - rect.top === 0 || w === 0 || h === 0) return;
|
|
67
|
+
firstPlaceRef.current = false;
|
|
68
|
+
let x = alignEnd ? rect.right - w : rect.left;
|
|
69
|
+
let y = side === 'top' ? rect.top - 8 - h : rect.bottom + 4;
|
|
70
|
+
x = Math.min(Math.max(x, MARGIN), window.innerWidth - w - MARGIN);
|
|
71
|
+
y = Math.min(Math.max(y, MARGIN), window.innerHeight - h - MARGIN);
|
|
72
|
+
setPos((prev) => (prev && Math.abs(prev.left - x) < 0.5 && Math.abs(prev.top - y) < 0.5 ? prev : { left: x, top: y }));
|
|
52
73
|
};
|
|
53
74
|
place();
|
|
54
75
|
// 内容换页/目录热更/滚动/视口变化都重定位——官方 place 的三路监听。
|
|
@@ -56,7 +77,29 @@ export function MenuSurface(props: MenuSurfaceProps): JSX.Element {
|
|
|
56
77
|
window.addEventListener('resize', place);
|
|
57
78
|
const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(place) : null;
|
|
58
79
|
observer?.observe(el);
|
|
80
|
+
observer?.observe(anchor);
|
|
81
|
+
|
|
82
|
+
// 布局重排可能既不产生 scroll 也不改窗口尺寸(输入框换型、分屏切换、
|
|
83
|
+
// 会话区从空到有内容),上面的监听全都收不到信号。用低频轮询兜住这类
|
|
84
|
+
// 变化:只有锚点位移超过阈值才重排——布局微抖时不跟随,否则菜单会跟着
|
|
85
|
+
// 一帧帧地跳(表现为「来回跳」)。
|
|
86
|
+
const start = anchor.getBoundingClientRect();
|
|
87
|
+
let lastLeft = start.left;
|
|
88
|
+
let lastTop = start.top;
|
|
89
|
+
let raf = 0;
|
|
90
|
+
const tick = (): void => {
|
|
91
|
+
const r = anchor.getBoundingClientRect();
|
|
92
|
+
if (Math.abs(r.left - lastLeft) > SHIFT_THRESHOLD || Math.abs(r.top - lastTop) > SHIFT_THRESHOLD) {
|
|
93
|
+
lastLeft = r.left;
|
|
94
|
+
lastTop = r.top;
|
|
95
|
+
place();
|
|
96
|
+
}
|
|
97
|
+
raf = requestAnimationFrame(tick);
|
|
98
|
+
};
|
|
99
|
+
raf = requestAnimationFrame(tick);
|
|
100
|
+
|
|
59
101
|
return () => {
|
|
102
|
+
cancelAnimationFrame(raf);
|
|
60
103
|
observer?.disconnect();
|
|
61
104
|
window.removeEventListener('scroll', place, true);
|
|
62
105
|
window.removeEventListener('resize', place);
|
|
@@ -66,15 +109,23 @@ export function MenuSurface(props: MenuSurfaceProps): JSX.Element {
|
|
|
66
109
|
useEffect(() => {
|
|
67
110
|
const onDown = (event: PointerEvent): void => {
|
|
68
111
|
if (ref.current && event.target instanceof Node && ref.current.contains(event.target)) return;
|
|
112
|
+
// 落在触发器上时不要在这里关:pointerdown 先于 click,先关掉会让触发器
|
|
113
|
+
// 自己的 onClick 读到 open=false 又开回来,表现为「点一次收不起来」。
|
|
114
|
+
// 开合交给触发器的 onClick 统一 toggle。
|
|
115
|
+
if (anchor && event.target instanceof Node && anchor.contains(event.target)) return;
|
|
69
116
|
onClose();
|
|
70
117
|
};
|
|
71
118
|
window.addEventListener('pointerdown', onDown, true);
|
|
72
119
|
return () => window.removeEventListener('pointerdown', onDown, true);
|
|
73
|
-
}, [onClose]);
|
|
120
|
+
}, [anchor, onClose]);
|
|
74
121
|
|
|
75
122
|
// 未定位的首帧隐藏但仍参与布局(官方 unplaced card 同款)——place() 在
|
|
76
123
|
// layout effect 里同步测得尺寸后即落位,无闪烁。
|
|
77
|
-
|
|
124
|
+
// 必须 portal 到 body:祖先链上的 transform / filter / backdrop-filter /
|
|
125
|
+
// contain 会把 position:fixed 的包含块从视口换成该祖先,left/top 便按它的
|
|
126
|
+
// 坐标系解释,菜单整体偏移到别处(实测能偏出 400px+,看起来「飘到角落」)。
|
|
127
|
+
// 官方数据行面板同样走 createPortal(document.body)。
|
|
128
|
+
return createPortal(
|
|
78
129
|
<div
|
|
79
130
|
ref={ref}
|
|
80
131
|
className="dci-menu"
|
|
@@ -88,7 +139,8 @@ export function MenuSurface(props: MenuSurfaceProps): JSX.Element {
|
|
|
88
139
|
}}
|
|
89
140
|
>
|
|
90
141
|
<div className="dci-viewport">{children}</div>
|
|
91
|
-
</div
|
|
142
|
+
</div>,
|
|
143
|
+
document.body,
|
|
92
144
|
);
|
|
93
145
|
}
|
|
94
146
|
|
|
@@ -76,7 +76,8 @@ export function ModelMenu(props: ModelMenuProps): JSX.Element {
|
|
|
76
76
|
const [open, setOpen] = useState(false);
|
|
77
77
|
const [view, setView] = useState<MenuView>('root');
|
|
78
78
|
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
79
|
-
const [anchor, setAnchor] = useState<
|
|
79
|
+
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
|
80
|
+
const [anchorRect, setAnchorRect] = useState<{ left: number; top: number; right: number; bottom: number } | null>(null);
|
|
80
81
|
|
|
81
82
|
// 服务缺席(installServices 未注入到)时禁用;svcEpoch 轮询兜底见 CodinputSurface。
|
|
82
83
|
const directory = getSessionDirectory(sessionId);
|
|
@@ -89,23 +90,13 @@ export function ModelMenu(props: ModelMenuProps): JSX.Element {
|
|
|
89
90
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
90
91
|
}, [open]);
|
|
91
92
|
|
|
92
|
-
useEffect(() => {
|
|
93
|
-
if (!open) return;
|
|
94
|
-
const onDown = (event: PointerEvent): void => {
|
|
95
|
-
if (refContains(rootMenuRef.current, event.target)) return;
|
|
96
|
-
close();
|
|
97
|
-
};
|
|
98
|
-
window.addEventListener('pointerdown', onDown, true);
|
|
99
|
-
return () => window.removeEventListener('pointerdown', onDown, true);
|
|
100
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
101
|
-
}, [open]);
|
|
102
|
-
|
|
103
93
|
const rootMenuRef = useRef<HTMLDivElement | null>(null);
|
|
104
94
|
|
|
105
95
|
const openMenu = (): void => {
|
|
106
96
|
const el = triggerRef.current;
|
|
107
97
|
if (!el) return;
|
|
108
|
-
setAnchor(el
|
|
98
|
+
setAnchor(el);
|
|
99
|
+
setAnchorRect(el.getBoundingClientRect());
|
|
109
100
|
setView('root');
|
|
110
101
|
setOpen(true);
|
|
111
102
|
};
|
|
@@ -150,9 +141,6 @@ export function ModelMenu(props: ModelMenuProps): JSX.Element {
|
|
|
150
141
|
}
|
|
151
142
|
};
|
|
152
143
|
|
|
153
|
-
const refContains = (el: Element | null, target: EventTarget | null): boolean =>
|
|
154
|
-
!!el && target instanceof Node && el.contains(target);
|
|
155
|
-
|
|
156
144
|
return (
|
|
157
145
|
<div style={{ position: 'relative', minWidth: 0 }}>
|
|
158
146
|
<button
|
|
@@ -179,7 +167,7 @@ export function ModelMenu(props: ModelMenuProps): JSX.Element {
|
|
|
179
167
|
|
|
180
168
|
{open && anchor ? (
|
|
181
169
|
<div ref={rootMenuRef}>
|
|
182
|
-
<MenuSurface anchor={anchor} onClose={close} alignEnd={true} minWidth={240}>
|
|
170
|
+
<MenuSurface anchor={anchor} initialRect={anchorRect} onClose={close} alignEnd={true} minWidth={240}>
|
|
183
171
|
{view === 'root' ? (
|
|
184
172
|
<>
|
|
185
173
|
<button type="button" className="dci-cell" onClick={() => setView('models')}>
|
|
@@ -58,7 +58,8 @@ export function PermissionMenu(props: PermissionMenuProps): JSX.Element | null {
|
|
|
58
58
|
const [confirmation, setConfirmation] = useState<string | null>(null);
|
|
59
59
|
const [acknowledged, setAcknowledged] = useState(false);
|
|
60
60
|
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
61
|
-
const [anchor, setAnchor] = useState<
|
|
61
|
+
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
|
62
|
+
const [anchorRect, setAnchorRect] = useState<{ left: number; top: number; right: number; bottom: number } | null>(null);
|
|
62
63
|
|
|
63
64
|
// locked/无值时收起(官方 useEffect 同语义)。
|
|
64
65
|
useEffect(() => {
|
|
@@ -130,7 +131,11 @@ export function PermissionMenu(props: PermissionMenuProps): JSX.Element | null {
|
|
|
130
131
|
disabled={disabled || busy}
|
|
131
132
|
onMouseDown={(e) => e.preventDefault()}
|
|
132
133
|
onClick={() => {
|
|
133
|
-
if (!open)
|
|
134
|
+
if (!open) {
|
|
135
|
+
const el = triggerRef.current ?? null;
|
|
136
|
+
setAnchor(el);
|
|
137
|
+
setAnchorRect(el ? el.getBoundingClientRect() : null);
|
|
138
|
+
}
|
|
134
139
|
setOpen(!open);
|
|
135
140
|
}}
|
|
136
141
|
>
|
|
@@ -146,7 +151,7 @@ export function PermissionMenu(props: PermissionMenuProps): JSX.Element | null {
|
|
|
146
151
|
</button>
|
|
147
152
|
|
|
148
153
|
{open && anchor ? (
|
|
149
|
-
<MenuSurface anchor={anchor} onClose={close}>
|
|
154
|
+
<MenuSurface anchor={anchor} initialRect={anchorRect} onClose={close}>
|
|
150
155
|
{items.map((item) => {
|
|
151
156
|
const active = item.id === currentValue;
|
|
152
157
|
return (
|
package/src/client/settings.tsx
CHANGED
|
@@ -109,7 +109,8 @@ function Selector(props: {
|
|
|
109
109
|
const { value, options, onChange, ariaLabel } = props;
|
|
110
110
|
const [open, setOpen] = useState(false);
|
|
111
111
|
const btnRef = useRef<HTMLButtonElement | null>(null);
|
|
112
|
-
const [anchor, setAnchor] = useState<
|
|
112
|
+
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
|
113
|
+
const [anchorRect, setAnchorRect] = useState<{ left: number; top: number; right: number; bottom: number } | null>(null);
|
|
113
114
|
const current = options.find((o) => o.value === value);
|
|
114
115
|
return (
|
|
115
116
|
<>
|
|
@@ -121,7 +122,11 @@ function Selector(props: {
|
|
|
121
122
|
aria-expanded={open}
|
|
122
123
|
aria-label={ariaLabel}
|
|
123
124
|
onClick={() => {
|
|
124
|
-
if (!open)
|
|
125
|
+
if (!open) {
|
|
126
|
+
const el = btnRef.current ?? null;
|
|
127
|
+
setAnchor(el);
|
|
128
|
+
setAnchorRect(el ? el.getBoundingClientRect() : null);
|
|
129
|
+
}
|
|
125
130
|
setOpen(!open);
|
|
126
131
|
}}
|
|
127
132
|
>
|
|
@@ -129,7 +134,7 @@ function Selector(props: {
|
|
|
129
134
|
<IconChevronOfficial size={14} />
|
|
130
135
|
</button>
|
|
131
136
|
{open && anchor ? (
|
|
132
|
-
<MenuSurface anchor={anchor} side="bottom" alignEnd onClose={() => setOpen(false)} minWidth={200}>
|
|
137
|
+
<MenuSurface anchor={anchor} initialRect={anchorRect} side="bottom" alignEnd onClose={() => setOpen(false)} minWidth={200}>
|
|
133
138
|
{options.map((opt) => (
|
|
134
139
|
<button
|
|
135
140
|
key={opt.value}
|