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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-codinput",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "description": "DSH 插件:把聊天输入框变成代码编辑器一样的输入风格(CodeMirror 6 接管 conversation.composer.bar),web 与桌面端通用。",
5
5
  "license": "MIT",
6
6
  "author": "Witherwithwinter",
@@ -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
- /** 触发器矩形(getBoundingClientRect 快照)。 */
22
- anchor: { left: number; top: number; right: number; bottom: number };
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
- let x = alignEnd ? anchor.right - w : anchor.left;
48
- let y = side === 'top' ? anchor.top - 8 - h : anchor.bottom + 4;
49
- if (w > 0) x = Math.min(Math.max(x, MARGIN), window.innerWidth - w - MARGIN);
50
- if (h > 0) y = Math.min(Math.max(y, MARGIN), window.innerHeight - h - MARGIN);
51
- setPos((prev) => (prev && prev.left === x && prev.top === y ? prev : { left: x, top: y }));
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
- return (
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<{ left: number; top: number; right: number; bottom: number } | null>(null);
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.getBoundingClientRect());
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<{ left: number; top: number; right: number; bottom: number } | null>(null);
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) setAnchor(triggerRef.current?.getBoundingClientRect() ?? null);
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 (
@@ -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<{ left: number; top: number; right: number; bottom: number } | null>(null);
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) setAnchor(btnRef.current?.getBoundingClientRect() ?? null);
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}