dsh-vscode-mode 0.22.0 → 0.23.0
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 +5 -3
- package/lib/client.js +3370 -85
- package/lib/client.js.map +2 -2
- package/lib/index.js +1167 -266
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +166 -0
- package/src/client/quickMenuHost.ts +46 -0
- package/src/client/quickMenuStore.ts +260 -0
- package/src/client/state/diffSummary.ts +9 -4
- package/src/client/styles/quickmenu.css +1049 -0
- package/src/client/ui/QuickMenuDialog.tsx +835 -0
- package/src/client/ui/QuickMenuLauncher.tsx +792 -0
- package/src/client/ui/QuickMenuPickers.ts +577 -0
- package/src/index.ts +2 -2
- package/src/paths.ts +10 -0
- package/src/pending/capture.ts +104 -75
- package/src/pending/persist.ts +6 -1
- package/src/pending/store.ts +10 -5
- package/src/pending/vcs.ts +2 -2
- package/src/pendingCompat.ts +3 -8
- package/src/pendingRpc.ts +140 -58
- package/src/quickMenu.ts +269 -0
- package/src/rpc.ts +37 -0
- package/src/shared/pathKey.ts +54 -0
- package/src/shared/quickMenu.ts +1009 -0
- package/src/shared/quickPick.ts +67 -0
- package/src/shared/rpc.ts +10 -0
- package/src/shared/skillCatalog.ts +60 -0
- package/src/skillCatalog.ts +138 -0
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode client — 快捷菜单悬浮按钮与多列级联菜单(SAO Utils 2 风格)。
|
|
3
|
+
*
|
|
4
|
+
* 交互约定:
|
|
5
|
+
* - 按钮可拖动(3px 阈值区分点击),松手落盘坐标;无保存坐标时锚定输入框左上角。
|
|
6
|
+
* - 点开是纵向滑动列表:行靠近列中心放大、向两端递减透明度,上下各 48px 渐隐(CSS mask)。
|
|
7
|
+
* - 每组列最后一行是「+ 新建」;悬停组 120ms 后在右侧展开它的一列(放不下自动翻左侧)。
|
|
8
|
+
* - 行悬停浮出「⋯」:编辑(弹窗内可删)/ 复制 / 删除;root 列底部固定「菜单设置」。
|
|
9
|
+
* 作者 ddj 2026年10月30号
|
|
10
|
+
*/
|
|
11
|
+
import React from 'react'
|
|
12
|
+
import { createPortal } from 'react-dom'
|
|
13
|
+
import { MenuGlobalDialog, QuickItemDialog } from './QuickMenuDialog.js'
|
|
14
|
+
import type { QuickMenuHost } from '../quickMenuHost.js'
|
|
15
|
+
import {
|
|
16
|
+
anchorLauncher,
|
|
17
|
+
bindingKey,
|
|
18
|
+
canCreateAt,
|
|
19
|
+
centerScale,
|
|
20
|
+
childrenOf,
|
|
21
|
+
composePrompt,
|
|
22
|
+
itemStatus,
|
|
23
|
+
placeColumn,
|
|
24
|
+
validateRule,
|
|
25
|
+
} from '../../shared/quickMenu.js'
|
|
26
|
+
import type { QuickMenuItem, QuickRect, ValidateContext } from '../../shared/quickMenu.js'
|
|
27
|
+
|
|
28
|
+
/** 悬浮按钮尺寸。 */
|
|
29
|
+
const LAUNCHER_SIZE = { width: 32, height: 32 }
|
|
30
|
+
/** 列宽。 */
|
|
31
|
+
const COL_WIDTH = 220
|
|
32
|
+
/** 行高(与 CSS 的 --qm-row 一致,用于计算子列对齐位置)。 */
|
|
33
|
+
const ROW_HEIGHT = 34
|
|
34
|
+
/** 列内上下内边距(与 CSS 一致)。 */
|
|
35
|
+
const COL_PAD = 8
|
|
36
|
+
/** 列间距。 */
|
|
37
|
+
const COL_GAP = 6
|
|
38
|
+
/** 列最大高度。 */
|
|
39
|
+
const COL_MAX_HEIGHT = 520
|
|
40
|
+
/** root 列底部「⚙ 菜单设置」底栏高度(与 CSS 的 flex-basis 一致),会占掉滚动区高度。 */
|
|
41
|
+
const FOOT_HEIGHT = 32
|
|
42
|
+
/** 「+ 新建」行的额外上边距(与 CSS 一致)。 */
|
|
43
|
+
const ADD_GAP = 5
|
|
44
|
+
/** 行与行之间的间隔(与 CSS 的 .edrv-qm-list gap 一致)。 */
|
|
45
|
+
const ROW_GAP = 1
|
|
46
|
+
/** 空态卡片的内容高度:标题 + 说明 + 主按钮,短于此会被裁掉说明文字。 */
|
|
47
|
+
const EMPTY_HEIGHT = 132
|
|
48
|
+
/** 列的上下边框(border-box,需从内容高度里让出)。 */
|
|
49
|
+
const COL_BORDER = 2
|
|
50
|
+
/** 拖动判定阈值(像素)。 */
|
|
51
|
+
const DRAG_SLOP = 3
|
|
52
|
+
/** 悬停展开意图延迟。 */
|
|
53
|
+
const HOVER_DELAY = 120
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 悬浮按钮的鲸鱼标记:取自 DSH 顶栏品牌鲸(同一条可视化轮廓),
|
|
57
|
+
* 实心填充在 19px 下比描边更认得出是鲸鱼而不是一个气泡。
|
|
58
|
+
*/
|
|
59
|
+
const WHALE_PATH = 'M22.9168 1.43018C22.6713 1.31018 22.5658 1.53918 22.4223 1.65519C22.3733 1.69269 22.3318 1.74169 22.2903 1.78669C21.9317 2.1697 21.5127 2.42121 20.9657 2.39121C20.1657 2.34621 19.4827 2.59771 18.8787 3.20973C18.7502 2.45521 18.3236 2.0047 17.6746 1.71569C17.3351 1.56568 16.9916 1.41518 16.7536 1.08867C16.5876 0.856163 16.5421 0.597155 16.4591 0.341647C16.4061 0.187643 16.3536 0.0301382 16.1761 0.00363739C15.9836 -0.0263635 15.9081 0.135141 15.8326 0.270145C15.5306 0.822162 15.4136 1.43018 15.4251 2.0462C15.4516 3.43174 16.0366 4.53527 17.1991 5.3203C17.3311 5.4103 17.3651 5.5003 17.3236 5.63181C17.2441 5.90231 17.1501 6.16482 17.0671 6.43533C17.0141 6.60784 16.9351 6.64584 16.7501 6.57033C16.1121 6.30383 15.5611 5.90931 15.074 5.4328C14.2475 4.63328 13.5 3.75075 12.568 3.05973C12.349 2.89822 12.13 2.74822 11.9034 2.60522C10.9524 1.68169 12.028 0.923165 12.277 0.833162C12.5375 0.739159 12.3675 0.41615 11.5259 0.42015C10.6844 0.42365 9.91439 0.705658 8.93286 1.08117C8.78935 1.13767 8.63835 1.17867 8.48384 1.21267C7.59332 1.04367 6.66829 1.00617 5.70226 1.11517C3.88321 1.31768 2.43016 2.1777 1.36213 3.64575C0.0790928 5.4103 -0.222916 7.41536 0.146595 9.50642C0.535106 11.7105 1.66014 13.535 3.38869 14.9616C5.18125 16.4406 7.24581 17.1657 9.60138 17.0266C11.0319 16.9441 12.6245 16.7526 14.421 15.2321C14.874 15.4576 15.3496 15.5476 16.1381 15.6151C16.7456 15.6716 17.3306 15.5851 17.7836 15.4911C18.4931 15.3411 18.4441 14.6841 18.1876 14.5636C16.1081 13.595 16.5646 13.9891 16.1496 13.67C17.2061 12.42 18.8202 10.1979 19.3182 7.17235C19.3672 6.83834 19.4297 6.36783 19.4222 6.09732C19.4182 5.93231 19.4562 5.86831 19.6447 5.84931C20.1657 5.78931 20.6712 5.64681 21.1357 5.3913C22.4833 4.65528 23.0268 3.44624 23.1548 1.9972C23.1738 1.77569 23.1508 1.54668 22.9168 1.43018ZM11.1749 14.4736C9.15936 12.889 8.18184 12.3675 7.77832 12.39C7.40081 12.4125 7.46881 12.8445 7.55182 13.126C7.63882 13.404 7.75182 13.5955 7.91033 13.8396C8.01983 14.0011 8.09533 14.2411 7.80083 14.4216C7.15181 14.8231 6.02327 14.2866 5.97027 14.2601C4.65673 13.4865 3.5587 12.4655 2.78467 11.069C2.03715 9.72493 1.60314 8.28289 1.53164 6.74384C1.51264 6.37233 1.62214 6.24082 1.99215 6.17332C2.47916 6.08332 2.98118 6.06432 3.46769 6.13582C5.52476 6.43633 7.27581 7.35586 8.74385 8.8129C9.58188 9.64243 10.2159 10.634 10.8689 11.6025C11.5634 12.631 12.3105 13.611 13.262 14.4146C13.598 14.6961 13.866 14.9101 14.1225 15.0681C13.349 15.1546 12.058 15.1731 11.1749 14.4746L11.1749 14.4736ZM12.141 8.25988C12.141 8.09488 12.273 7.96338 12.439 7.96338C12.4765 7.96338 12.5105 7.97088 12.541 7.98188C12.5825 7.99688 12.6205 8.01938 12.6505 8.05338C12.7035 8.10588 12.7335 8.18088 12.7335 8.25988C12.7335 8.42489 12.6015 8.55639 12.4355 8.55639C12.2695 8.55639 12.141 8.42489 12.141 8.25988ZM15.1415 9.79893C14.949 9.87793 14.7565 9.94544 14.5715 9.95294C14.2845 9.96794 13.9715 9.85143 13.8015 9.70893C13.5375 9.48742 13.3485 9.36342 13.2695 8.97691C13.2355 8.8119 13.2545 8.55639 13.2845 8.40989C13.3525 8.09438 13.277 7.89187 13.0545 7.70787C12.8735 7.55786 12.643 7.51636 12.39 7.51636C12.2955 7.51636 12.209 7.47486 12.1445 7.44136C12.039 7.38886 11.9519 7.25735 12.035 7.09585C12.0615 7.04335 12.19 6.91584 12.22 6.89334C12.5635 6.69784 12.9595 6.76184 13.326 6.90834C13.6655 7.04735 13.9225 7.30236 14.292 7.66287C14.6695 8.09838 14.7375 8.21838 14.9525 8.54539C15.1225 8.8009 15.277 9.06341 15.3831 9.36392C15.4471 9.55142 15.3641 9.70493 15.1415 9.79893Z'
|
|
60
|
+
|
|
61
|
+
/** 当前弹窗状态。 */
|
|
62
|
+
interface DialogState {
|
|
63
|
+
kind: 'none' | 'item' | 'global'
|
|
64
|
+
item?: QuickMenuItem
|
|
65
|
+
parentId?: string | null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 行内小菜单状态。 */
|
|
69
|
+
interface RowMenuState {
|
|
70
|
+
item: QuickMenuItem
|
|
71
|
+
rect: QuickRect
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 数值夹取。
|
|
76
|
+
* @author ddj 2026年10月30号
|
|
77
|
+
* @param value 原始值
|
|
78
|
+
* @param min 下限
|
|
79
|
+
* @param max 上限
|
|
80
|
+
* @returns 夹取结果
|
|
81
|
+
*/
|
|
82
|
+
function clamp(value: number, min: number, max: number): number {
|
|
83
|
+
if (!Number.isFinite(value)) return min
|
|
84
|
+
return Math.min(max, Math.max(min, value))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 两个矩形是否等价(测量轮询用,避免无意义重渲染)。
|
|
89
|
+
* @author ddj 2026年10月30号
|
|
90
|
+
* @param left 矩形 A
|
|
91
|
+
* @param right 矩形 B
|
|
92
|
+
* @returns 是否等价
|
|
93
|
+
*/
|
|
94
|
+
function sameRect(left: QuickRect, right: QuickRect): boolean {
|
|
95
|
+
return left.left === right.left && left.top === right.top && left.width === right.width && left.height === right.height
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 取当前可见的输入框锚点。
|
|
100
|
+
* 页面里可能同时存在多个(隐藏会话的 seat 位于视口外,量到的 top 会超过浮层高度,
|
|
101
|
+
* 把按钮夹到屏幕底边压住输入框),因此优先选真正落在视口内、面积最大的那个。
|
|
102
|
+
* @author ddj 2026年10月30号
|
|
103
|
+
* @param layerRect 浮层矩形(按钮坐标以它为基准)
|
|
104
|
+
* @returns 相对浮层的输入框矩形;没有可用锚点时返回 null
|
|
105
|
+
*/
|
|
106
|
+
function pickSeatRect(layerRect: DOMRect): QuickRect | null {
|
|
107
|
+
const seats = Array.from(document.querySelectorAll('[data-composer-seat]'))
|
|
108
|
+
let best: { rect: QuickRect; visibleArea: number } | null = null
|
|
109
|
+
for (const seat of seats) {
|
|
110
|
+
const rect = seat.getBoundingClientRect()
|
|
111
|
+
if (rect.width < 40 || rect.height < 20) continue
|
|
112
|
+
const visibleTop = Math.max(rect.top, 0)
|
|
113
|
+
const visibleBottom = Math.min(rect.bottom, window.innerHeight)
|
|
114
|
+
if (visibleBottom <= visibleTop) continue
|
|
115
|
+
const visibleArea = (visibleBottom - visibleTop) * rect.width
|
|
116
|
+
if (best && best.visibleArea >= visibleArea) continue
|
|
117
|
+
best = {
|
|
118
|
+
visibleArea,
|
|
119
|
+
rect: {
|
|
120
|
+
left: rect.left - layerRect.left,
|
|
121
|
+
top: rect.top - layerRect.top,
|
|
122
|
+
width: rect.width,
|
|
123
|
+
height: rect.height,
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return best ? best.rect : null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 今日日期文本({{date}} 占位符)。
|
|
132
|
+
* @author ddj 2026年10月30号
|
|
133
|
+
* @returns YYYY-MM-DD
|
|
134
|
+
*/
|
|
135
|
+
function todayText(): string {
|
|
136
|
+
const now = new Date()
|
|
137
|
+
const month = String(now.getMonth() + 1).padStart(2, '0')
|
|
138
|
+
const day = String(now.getDate()).padStart(2, '0')
|
|
139
|
+
return now.getFullYear() + '-' + month + '-' + day
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 当前文档选择文本({{selection}} 占位符;拿不到就是空串)。
|
|
144
|
+
* @author ddj 2026年10月30号
|
|
145
|
+
* @returns 选中文本
|
|
146
|
+
*/
|
|
147
|
+
function selectionText(): string {
|
|
148
|
+
try {
|
|
149
|
+
return window.getSelection()?.toString() ?? ''
|
|
150
|
+
} catch {
|
|
151
|
+
return ''
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 悬浮按钮 + 多列级联菜单。
|
|
157
|
+
* @author ddj 2026年10月30号
|
|
158
|
+
* @param props host(store + 发送/规则/技能桥接)
|
|
159
|
+
* @returns 菜单元素(不显示时为 null)
|
|
160
|
+
*/
|
|
161
|
+
export function QuickMenuLauncher(props: { host: QuickMenuHost }): React.ReactElement | null {
|
|
162
|
+
const { host } = props
|
|
163
|
+
const store = host.store
|
|
164
|
+
// 订阅 store:文档/绑定快照/挂载标志变化都会重渲染
|
|
165
|
+
React.useSyncExternalStore(store.subscribe, store.getVersion, store.getVersion)
|
|
166
|
+
const state = store.getState()
|
|
167
|
+
const doc = state.doc
|
|
168
|
+
const [layerSize, setLayerSize] = React.useState({ width: 1280, height: 800 })
|
|
169
|
+
const [measured, setMeasured] = React.useState(false)
|
|
170
|
+
const [seat, setSeat] = React.useState<QuickRect | null>(null)
|
|
171
|
+
const [open, setOpen] = React.useState(false)
|
|
172
|
+
const [dragging, setDragging] = React.useState(false)
|
|
173
|
+
const [dragPos, setDragPos] = React.useState<{ left: number; top: number } | null>(null)
|
|
174
|
+
const [openPath, setOpenPath] = React.useState<string[]>([])
|
|
175
|
+
const [hoverRow, setHoverRow] = React.useState<Record<number, number>>({})
|
|
176
|
+
const [scrolls, setScrolls] = React.useState<Record<number, number>>({})
|
|
177
|
+
const [rowMenu, setRowMenu] = React.useState<RowMenuState | null>(null)
|
|
178
|
+
const [dialog, setDialog] = React.useState<DialogState>({ kind: 'none' })
|
|
179
|
+
const [active, setActive] = React.useState<{ depth: number; index: number }>({ depth: 0, index: 0 })
|
|
180
|
+
const hoverTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 测量浮层与输入框锚点(浮层坐标系)。
|
|
184
|
+
* 值不变时返回原状态,避免轮询测量引起无意义重渲染。
|
|
185
|
+
* @author ddj 2026年10月30号
|
|
186
|
+
*/
|
|
187
|
+
const measure = React.useCallback((): void => {
|
|
188
|
+
const layerEl = document.querySelector('[data-shell-overlay]')
|
|
189
|
+
if (!layerEl) {
|
|
190
|
+
setLayerSize((prev) => (prev.width === window.innerWidth && prev.height === window.innerHeight ? prev : { width: window.innerWidth, height: window.innerHeight }))
|
|
191
|
+
setSeat(null)
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
const layerRect = layerEl.getBoundingClientRect()
|
|
195
|
+
setLayerSize((prev) => (prev.width === layerRect.width && prev.height === layerRect.height ? prev : { width: layerRect.width, height: layerRect.height }))
|
|
196
|
+
setMeasured((prev) => (prev ? prev : true))
|
|
197
|
+
const next = pickSeatRect(layerRect)
|
|
198
|
+
setSeat((prev) => (prev && next && sameRect(prev, next) ? prev : next))
|
|
199
|
+
}, [])
|
|
200
|
+
|
|
201
|
+
React.useEffect(() => {
|
|
202
|
+
store.markMounted(true)
|
|
203
|
+
measure()
|
|
204
|
+
void store.load()
|
|
205
|
+
const onResize = (): void => measure()
|
|
206
|
+
window.addEventListener('resize', onResize)
|
|
207
|
+
// 冷启动时输入框还没布局完,首帧量到的锚点是错的:前几秒补测几次,
|
|
208
|
+
// 之后用 ResizeObserver + 低频轮询兜底(measure 内部做等值短路,不会抖)。
|
|
209
|
+
const timers = [80, 320, 900, 2000].map((delay) => setTimeout(measure, delay))
|
|
210
|
+
const layerEl = document.querySelector('[data-shell-overlay]')
|
|
211
|
+
const seatEl = document.querySelector('[data-composer-seat]')
|
|
212
|
+
let observer: ResizeObserver | null = null
|
|
213
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
214
|
+
observer = new ResizeObserver(() => measure())
|
|
215
|
+
if (layerEl) observer.observe(layerEl)
|
|
216
|
+
if (seatEl) observer.observe(seatEl)
|
|
217
|
+
}
|
|
218
|
+
const poll = setInterval(measure, 1200)
|
|
219
|
+
return () => {
|
|
220
|
+
window.removeEventListener('resize', onResize)
|
|
221
|
+
timers.forEach((timer) => clearTimeout(timer))
|
|
222
|
+
clearInterval(poll)
|
|
223
|
+
observer?.disconnect()
|
|
224
|
+
store.markMounted(false)
|
|
225
|
+
}
|
|
226
|
+
}, [measure, store])
|
|
227
|
+
|
|
228
|
+
// 打开时重新测量并刷新绑定快照(规则/技能可能在别处变了)
|
|
229
|
+
React.useEffect(() => {
|
|
230
|
+
if (!open) return
|
|
231
|
+
measure()
|
|
232
|
+
void store.refreshBindings()
|
|
233
|
+
}, [open, measure, store])
|
|
234
|
+
|
|
235
|
+
const ctx = store.getContext()
|
|
236
|
+
const basePos = React.useMemo(() => {
|
|
237
|
+
const maxLeft = Math.max(8, layerSize.width - LAUNCHER_SIZE.width - 8)
|
|
238
|
+
const maxTop = Math.max(8, layerSize.height - LAUNCHER_SIZE.height - 8)
|
|
239
|
+
const savedX = doc.launcher.x
|
|
240
|
+
const savedY = doc.launcher.y
|
|
241
|
+
// 存的坐标可能来自别的窗口尺寸(甚至超界),先量到真实浮层再判断是否还落在可视区内;
|
|
242
|
+
// 超界就回落到输入框左上角,避免按钮被夹在屏幕底边压住输入框。
|
|
243
|
+
if (savedX !== null && savedY !== null) {
|
|
244
|
+
const inBounds = savedX >= 8 && savedY >= 8 && savedX <= maxLeft && savedY <= maxTop
|
|
245
|
+
if (inBounds || !measured) return { left: clamp(savedX, 8, maxLeft), top: clamp(savedY, 8, maxTop) }
|
|
246
|
+
}
|
|
247
|
+
const layer: QuickRect = { left: 0, top: 0, width: layerSize.width, height: layerSize.height }
|
|
248
|
+
return anchorLauncher(seat, layer, LAUNCHER_SIZE, 8)
|
|
249
|
+
}, [doc.launcher.x, doc.launcher.y, seat, layerSize, measured])
|
|
250
|
+
|
|
251
|
+
const launcherPos = dragPos ?? basePos
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* 计算每一列的矩形(独立定位,子列右展放不下则自动翻到左侧)。
|
|
255
|
+
* @author ddj 2026年10月30号
|
|
256
|
+
*/
|
|
257
|
+
const columnRects = React.useMemo((): QuickRect[] => {
|
|
258
|
+
const rects: QuickRect[] = []
|
|
259
|
+
const layerRect: QuickRect = { left: 0, top: 0, width: layerSize.width, height: layerSize.height }
|
|
260
|
+
let anchor: QuickRect = { left: launcherPos.left, top: launcherPos.top, width: LAUNCHER_SIZE.width, height: LAUNCHER_SIZE.height }
|
|
261
|
+
const depthCount = openPath.length + 1
|
|
262
|
+
for (let depth = 0; depth < depthCount; depth += 1) {
|
|
263
|
+
const parentId = depth === 0 ? null : openPath[depth - 1]
|
|
264
|
+
const items = childrenOf(doc, parentId, ctx.cwd)
|
|
265
|
+
const extra = canCreateAt(doc, parentId) ? 1 : 0
|
|
266
|
+
// 列高必须把 root 底栏和「新建」行间距算进去,否则滚动区被底栏挤压、行被裁掉;
|
|
267
|
+
// 空列不渲染「+ 新建」行而是空态卡片,按 EMPTY_HEIGHT 给足高度,避免说明文字被切。
|
|
268
|
+
const foot = depth === 0 ? FOOT_HEIGHT : 0
|
|
269
|
+
const rowCount = items.length + extra
|
|
270
|
+
const rowHeight = items.length === 0
|
|
271
|
+
? EMPTY_HEIGHT
|
|
272
|
+
: items.length * ROW_HEIGHT
|
|
273
|
+
+ (extra > 0 ? ROW_HEIGHT + ADD_GAP : 0)
|
|
274
|
+
+ Math.max(0, rowCount - 1) * ROW_GAP
|
|
275
|
+
+ COL_PAD * 2
|
|
276
|
+
const height = Math.min(COL_MAX_HEIGHT, Math.max(ROW_HEIGHT * 2 + foot, rowHeight + foot + COL_BORDER))
|
|
277
|
+
const placed = placeColumn(anchor, { width: COL_WIDTH, height }, layerSize, COL_GAP)
|
|
278
|
+
let rect: QuickRect = { ...placed }
|
|
279
|
+
if (depth > 0) {
|
|
280
|
+
const rowIndex = hoverRow[depth - 1] ?? 0
|
|
281
|
+
const wantedTop = anchor.top + COL_PAD + rowIndex * ROW_HEIGHT - (scrolls[depth - 1] ?? 0)
|
|
282
|
+
rect = { ...rect, top: clamp(wantedTop, 8, Math.max(8, layerRect.height - height - 8)) }
|
|
283
|
+
}
|
|
284
|
+
rects.push(rect)
|
|
285
|
+
anchor = rect
|
|
286
|
+
}
|
|
287
|
+
return rects
|
|
288
|
+
}, [doc, ctx.cwd, launcherPos.left, launcherPos.top, layerSize, openPath, hoverRow, scrolls])
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* 关闭菜单并清空级联状态。
|
|
292
|
+
* @author ddj 2026年10月30号
|
|
293
|
+
*/
|
|
294
|
+
const closeMenu = React.useCallback((): void => {
|
|
295
|
+
setOpen(false)
|
|
296
|
+
setRowMenu(null)
|
|
297
|
+
setOpenPath([])
|
|
298
|
+
setHoverRow({})
|
|
299
|
+
setScrolls({})
|
|
300
|
+
setActive({ depth: 0, index: 0 })
|
|
301
|
+
}, [])
|
|
302
|
+
|
|
303
|
+
// 点空白关闭 + 键盘操作(↑↓ 选择、→ 展开组、← 收起、Enter 执行、Esc 关闭)
|
|
304
|
+
React.useEffect(() => {
|
|
305
|
+
if (!open) return
|
|
306
|
+
const onPointerDown = (event: PointerEvent): void => {
|
|
307
|
+
const target = event.target as Element | null
|
|
308
|
+
if (target && target.closest('[data-qm-root="1"]')) return
|
|
309
|
+
closeMenu()
|
|
310
|
+
}
|
|
311
|
+
const onKeyDown = (event: KeyboardEvent): void => {
|
|
312
|
+
if (event.key === 'Escape') {
|
|
313
|
+
event.stopPropagation()
|
|
314
|
+
closeMenu()
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
const parentId = active.depth === 0 ? null : openPath[active.depth - 1]
|
|
318
|
+
const items = childrenOf(doc, parentId, ctx.cwd)
|
|
319
|
+
if (items.length === 0) return
|
|
320
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
321
|
+
event.preventDefault()
|
|
322
|
+
const delta = event.key === 'ArrowDown' ? 1 : -1
|
|
323
|
+
const index = (active.index + delta + items.length) % items.length
|
|
324
|
+
setActive({ depth: active.depth, index })
|
|
325
|
+
setHoverRow((prev) => ({ ...prev, [active.depth]: index }))
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
if (event.key === 'ArrowRight') {
|
|
329
|
+
const item = items[Math.min(active.index, items.length - 1)]
|
|
330
|
+
if (item && item.type === 'group') {
|
|
331
|
+
event.preventDefault()
|
|
332
|
+
setOpenPath((prev) => prev.slice(0, active.depth).concat(item.id))
|
|
333
|
+
setActive({ depth: active.depth + 1, index: 0 })
|
|
334
|
+
}
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
if (event.key === 'ArrowLeft' && active.depth > 0) {
|
|
338
|
+
event.preventDefault()
|
|
339
|
+
setOpenPath((prev) => prev.slice(0, active.depth - 1))
|
|
340
|
+
setActive({ depth: active.depth - 1, index: 0 })
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
if (event.key === 'Enter') {
|
|
344
|
+
const item = items[Math.min(active.index, items.length - 1)]
|
|
345
|
+
if (item) void activate(item)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
window.addEventListener('pointerdown', onPointerDown, true)
|
|
349
|
+
window.addEventListener('keydown', onKeyDown, true)
|
|
350
|
+
return () => {
|
|
351
|
+
window.removeEventListener('pointerdown', onPointerDown, true)
|
|
352
|
+
window.removeEventListener('keydown', onKeyDown, true)
|
|
353
|
+
}
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* 拖动与点击的分流:移动超过阈值才算拖拽,否则当作点击开关。
|
|
358
|
+
* @author ddj 2026年10月30号
|
|
359
|
+
* @param event 指针按下事件
|
|
360
|
+
*/
|
|
361
|
+
function beginDrag(event: React.PointerEvent<HTMLDivElement>): void {
|
|
362
|
+
if (event.button !== 0) return
|
|
363
|
+
event.preventDefault()
|
|
364
|
+
event.stopPropagation()
|
|
365
|
+
const start = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, left: launcherPos.left, top: launcherPos.top, moved: false }
|
|
366
|
+
let last = { left: launcherPos.left, top: launcherPos.top }
|
|
367
|
+
const onMove = (moveEvent: PointerEvent): void => {
|
|
368
|
+
if (moveEvent.pointerId !== start.pointerId) return
|
|
369
|
+
const dx = moveEvent.clientX - start.x
|
|
370
|
+
const dy = moveEvent.clientY - start.y
|
|
371
|
+
if (!start.moved && Math.abs(dx) + Math.abs(dy) < DRAG_SLOP) return
|
|
372
|
+
start.moved = true
|
|
373
|
+
setDragging(true)
|
|
374
|
+
last = {
|
|
375
|
+
left: clamp(start.left + dx, 8, Math.max(8, layerSize.width - LAUNCHER_SIZE.width - 8)),
|
|
376
|
+
top: clamp(start.top + dy, 8, Math.max(8, layerSize.height - LAUNCHER_SIZE.height - 8)),
|
|
377
|
+
}
|
|
378
|
+
setDragPos(last)
|
|
379
|
+
}
|
|
380
|
+
const finish = (): void => {
|
|
381
|
+
window.removeEventListener('pointermove', onMove, true)
|
|
382
|
+
window.removeEventListener('pointerup', onUp, true)
|
|
383
|
+
window.removeEventListener('pointercancel', onUp, true)
|
|
384
|
+
window.removeEventListener('blur', onUp, true)
|
|
385
|
+
setDragging(false)
|
|
386
|
+
setDragPos(null)
|
|
387
|
+
if (start.moved) {
|
|
388
|
+
store.patchLauncher(last.left, last.top)
|
|
389
|
+
void store.flushLauncher()
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
// 未移动视为点击:开 / 关菜单
|
|
393
|
+
if (open) closeMenu()
|
|
394
|
+
else setOpen(true)
|
|
395
|
+
}
|
|
396
|
+
const onUp = (): void => finish()
|
|
397
|
+
window.addEventListener('pointermove', onMove, true)
|
|
398
|
+
window.addEventListener('pointerup', onUp, true)
|
|
399
|
+
window.addEventListener('pointercancel', onUp, true)
|
|
400
|
+
window.addEventListener('blur', onUp, true)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* 悬停行:按钮只高亮;组延迟后在右侧展开(防抖动)。
|
|
405
|
+
* @author ddj 2026年10月30号
|
|
406
|
+
* @param depth 所在列序号
|
|
407
|
+
* @param index 行序号
|
|
408
|
+
* @param item 菜单项
|
|
409
|
+
*/
|
|
410
|
+
function hoverItem(depth: number, index: number, item: QuickMenuItem): void {
|
|
411
|
+
setHoverRow((prev) => ({ ...prev, [depth]: index }))
|
|
412
|
+
setActive({ depth, index })
|
|
413
|
+
if (hoverTimer.current !== null) clearTimeout(hoverTimer.current)
|
|
414
|
+
if (item.type !== 'group') {
|
|
415
|
+
setOpenPath((prev) => prev.slice(0, depth))
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
hoverTimer.current = setTimeout(() => {
|
|
419
|
+
setOpenPath((prev) => prev.slice(0, depth).concat(item.id))
|
|
420
|
+
}, HOVER_DELAY)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* 执行一项:组则展开,按钮则合成文本后发送/填入。
|
|
425
|
+
* @author ddj 2026年10月30号
|
|
426
|
+
* @param item 菜单项
|
|
427
|
+
*/
|
|
428
|
+
async function activate(item: QuickMenuItem): Promise<void> {
|
|
429
|
+
if (item.type === 'group') return
|
|
430
|
+
const inlineContents: Record<string, string> = {}
|
|
431
|
+
if (item.ruleMode === 'inline') {
|
|
432
|
+
for (const binding of item.rules) {
|
|
433
|
+
if (validateRule(binding, ctx).state !== 'ok') continue
|
|
434
|
+
try {
|
|
435
|
+
inlineContents[bindingKey(binding)] = await host.readRule(binding)
|
|
436
|
+
} catch {
|
|
437
|
+
// 读不到正文时 composePrompt 会给出告警并跳过
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
const composed = composePrompt(item, { ...ctx, inlineContents, selection: selectionText(), date: todayText() })
|
|
442
|
+
if (composed.text.trim() === '') {
|
|
443
|
+
host.notify('「' + item.name + '」没有可发送的内容')
|
|
444
|
+
return
|
|
445
|
+
}
|
|
446
|
+
if (composed.warnings.length > 0) {
|
|
447
|
+
host.notify('已跳过 ' + composed.warnings.length + ' 项失效绑定:' + composed.warnings[0])
|
|
448
|
+
}
|
|
449
|
+
const action = item.clickAction ?? doc.clickAction
|
|
450
|
+
closeMenu()
|
|
451
|
+
const result = action === 'fill' ? await host.fill(composed.text) : await host.send(composed.text)
|
|
452
|
+
if (!result.ok) host.notify(result.message ?? '操作失败')
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* 复制一项(同组同级追加副本,名字加「副本」)。
|
|
457
|
+
* @author ddj 2026年10月30号
|
|
458
|
+
* @param item 源项
|
|
459
|
+
*/
|
|
460
|
+
async function duplicate(item: QuickMenuItem): Promise<void> {
|
|
461
|
+
const copy: QuickMenuItem = item.type === 'group'
|
|
462
|
+
? { ...item, id: store.nextId('group'), name: item.name + ' 副本', order: item.order + 1 }
|
|
463
|
+
: { ...item, id: store.nextId('button'), name: item.name + ' 副本', order: item.order + 1 }
|
|
464
|
+
const result = await store.createItem(copy)
|
|
465
|
+
if (!result.ok) host.notify(result.error ?? '复制失败')
|
|
466
|
+
setRowMenu(null)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* 删除一项(组连带子项)。
|
|
471
|
+
* @author ddj 2026年10月30号
|
|
472
|
+
* @param item 目标项
|
|
473
|
+
*/
|
|
474
|
+
async function removeItem(item: QuickMenuItem): Promise<void> {
|
|
475
|
+
setRowMenu(null)
|
|
476
|
+
const suffix = item.type === 'group' ? '及其全部子项' : ''
|
|
477
|
+
if (!window.confirm('确认删除「' + item.name + '」' + suffix + '?')) return
|
|
478
|
+
const result = await store.removeItem(item.id)
|
|
479
|
+
if (!result.ok) host.notify(result.error ?? '删除失败')
|
|
480
|
+
setOpenPath([])
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const launcher = (
|
|
484
|
+
<div
|
|
485
|
+
className={'edrv-qm-launcher' + (dragging ? ' edrv-qm-launcher--drag' : '') + (open ? ' edrv-qm-launcher--open' : '')}
|
|
486
|
+
style={{ left: launcherPos.left, top: launcherPos.top }}
|
|
487
|
+
title="快捷菜单(拖动可移动,右键更多)"
|
|
488
|
+
role="button"
|
|
489
|
+
tabIndex={0}
|
|
490
|
+
aria-label="快捷菜单"
|
|
491
|
+
aria-expanded={open}
|
|
492
|
+
onPointerDown={beginDrag}
|
|
493
|
+
onKeyDown={(event) => {
|
|
494
|
+
if (event.key !== 'Enter' && event.key !== ' ') return
|
|
495
|
+
event.preventDefault()
|
|
496
|
+
if (open) closeMenu()
|
|
497
|
+
else setOpen(true)
|
|
498
|
+
}}
|
|
499
|
+
onContextMenu={(event) => {
|
|
500
|
+
event.preventDefault()
|
|
501
|
+
setOpen(false)
|
|
502
|
+
setDialog({ kind: 'global' })
|
|
503
|
+
}}
|
|
504
|
+
>
|
|
505
|
+
<svg viewBox="0 0 23.16 17.04" aria-hidden="true">
|
|
506
|
+
<path d={WHALE_PATH} fill="currentColor" />
|
|
507
|
+
</svg>
|
|
508
|
+
</div>
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
// 隐藏态(设置在「菜单设置」里可以关掉按钮):收成一枚半透明小圆点,
|
|
512
|
+
// 仍可拖动与点开,避免「关掉之后再也打不开」的死角。
|
|
513
|
+
const miniDot = (
|
|
514
|
+
<div
|
|
515
|
+
className="edrv-qm-launcher edrv-qm-launcher--mini"
|
|
516
|
+
style={{ left: launcherPos.left, top: launcherPos.top }}
|
|
517
|
+
title="快捷菜单已隐藏,点击展开(右键打开设置)"
|
|
518
|
+
role="button"
|
|
519
|
+
tabIndex={0}
|
|
520
|
+
aria-label="快捷菜单(已隐藏)"
|
|
521
|
+
onPointerDown={beginDrag}
|
|
522
|
+
onKeyDown={(event) => {
|
|
523
|
+
if (event.key !== 'Enter' && event.key !== ' ') return
|
|
524
|
+
event.preventDefault()
|
|
525
|
+
if (open) closeMenu()
|
|
526
|
+
else setOpen(true)
|
|
527
|
+
}}
|
|
528
|
+
onContextMenu={(event) => {
|
|
529
|
+
event.preventDefault()
|
|
530
|
+
setOpen(false)
|
|
531
|
+
setDialog({ kind: 'global' })
|
|
532
|
+
}}
|
|
533
|
+
/>
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
const panels = open ? columnRects.map((rect, depth) => {
|
|
537
|
+
const parentId = depth === 0 ? null : openPath[depth - 1]
|
|
538
|
+
return (
|
|
539
|
+
<QuickColumn
|
|
540
|
+
key={depth}
|
|
541
|
+
depth={depth}
|
|
542
|
+
rect={rect}
|
|
543
|
+
items={childrenOf(doc, parentId, ctx.cwd)}
|
|
544
|
+
canAdd={canCreateAt(doc, parentId)}
|
|
545
|
+
ctx={ctx}
|
|
546
|
+
status={state.status}
|
|
547
|
+
error={state.error}
|
|
548
|
+
activeIndex={active.depth === depth ? active.index : -1}
|
|
549
|
+
scrollTop={scrolls[depth] ?? 0}
|
|
550
|
+
onScroll={(top) => setScrolls((prev) => ({ ...prev, [depth]: top }))}
|
|
551
|
+
onHover={(index, item) => hoverItem(depth, index, item)}
|
|
552
|
+
onActivate={(item) => { void activate(item) }}
|
|
553
|
+
onMore={(item, rowRect) => setRowMenu({ item, rect: rowRect })}
|
|
554
|
+
onAdd={() => setDialog({ kind: 'item', parentId })}
|
|
555
|
+
onSettings={depth === 0 ? () => { setOpen(false); setDialog({ kind: 'global' }) } : undefined}
|
|
556
|
+
onRetry={() => { void store.load(true) }}
|
|
557
|
+
onEmptyHint={depth === 0
|
|
558
|
+
? '把常做的提问连同技能、规则存成一个按钮,点一下就发出去。'
|
|
559
|
+
: '组里还没有内容,新建一个按钮或子组。'}
|
|
560
|
+
/>
|
|
561
|
+
)
|
|
562
|
+
}) : null
|
|
563
|
+
|
|
564
|
+
return (
|
|
565
|
+
<>
|
|
566
|
+
<div data-edrv-view="1" className="edrv-qm-root" data-qm-root="1">
|
|
567
|
+
{doc.enabled ? launcher : miniDot}
|
|
568
|
+
</div>
|
|
569
|
+
{open
|
|
570
|
+
? createPortal(
|
|
571
|
+
// 菜单本体挂到 body:shell.overlay 的层级(z-index 20)压不过输入框卡片,
|
|
572
|
+
// 直接留在槽里会被输入框盖住;portal 后自成一个高层,专用于这块临时浮层。
|
|
573
|
+
<div {...{ 'data-edrv-view': '1' }} className="edrv-qm-menu" data-qm-root="1">
|
|
574
|
+
{panels}
|
|
575
|
+
{rowMenu ? (
|
|
576
|
+
<RowMenu
|
|
577
|
+
state={rowMenu}
|
|
578
|
+
onEdit={() => { setDialog({ kind: 'item', item: rowMenu.item }); setRowMenu(null) }}
|
|
579
|
+
onDuplicate={() => { void duplicate(rowMenu.item) }}
|
|
580
|
+
onDelete={() => { void removeItem(rowMenu.item) }}
|
|
581
|
+
/>
|
|
582
|
+
) : null}
|
|
583
|
+
</div>,
|
|
584
|
+
document.body,
|
|
585
|
+
)
|
|
586
|
+
: null}
|
|
587
|
+
{dialog.kind === 'item' ? (
|
|
588
|
+
<QuickItemDialog
|
|
589
|
+
host={host}
|
|
590
|
+
item={dialog.item}
|
|
591
|
+
parentId={dialog.item ? dialog.item.parentId : dialog.parentId ?? null}
|
|
592
|
+
onClose={() => setDialog({ kind: 'none' })}
|
|
593
|
+
/>
|
|
594
|
+
) : null}
|
|
595
|
+
{dialog.kind === 'global' ? (
|
|
596
|
+
<MenuGlobalDialog host={host} onClose={() => setDialog({ kind: 'none' })} />
|
|
597
|
+
) : null}
|
|
598
|
+
</>
|
|
599
|
+
)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** 单列 props。 */
|
|
603
|
+
interface QuickColumnProps {
|
|
604
|
+
depth: number
|
|
605
|
+
rect: QuickRect
|
|
606
|
+
items: QuickMenuItem[]
|
|
607
|
+
canAdd: boolean
|
|
608
|
+
ctx: ValidateContext
|
|
609
|
+
status: 'idle' | 'loading' | 'ready' | 'error'
|
|
610
|
+
error?: string
|
|
611
|
+
activeIndex: number
|
|
612
|
+
scrollTop: number
|
|
613
|
+
onScroll(top: number): void
|
|
614
|
+
onHover(index: number, item: QuickMenuItem): void
|
|
615
|
+
onActivate(item: QuickMenuItem): void
|
|
616
|
+
onMore(item: QuickMenuItem, rect: QuickRect): void
|
|
617
|
+
onAdd(): void
|
|
618
|
+
onSettings?: () => void
|
|
619
|
+
onRetry(): void
|
|
620
|
+
onEmptyHint: string
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* 一列(纵向滑动列表 + 末行「+ 新建」 + root 底部设置)。
|
|
625
|
+
* @author ddj 2026年10月30号
|
|
626
|
+
* @param props 列数据与回调
|
|
627
|
+
* @returns 列元素
|
|
628
|
+
*/
|
|
629
|
+
function QuickColumn(props: QuickColumnProps): React.ReactElement {
|
|
630
|
+
const scrollRef = React.useRef<HTMLDivElement | null>(null)
|
|
631
|
+
const count = props.items.length + (props.canAdd ? 1 : 0)
|
|
632
|
+
const isEmpty = props.items.length === 0 && props.status !== 'loading' && props.status !== 'error'
|
|
633
|
+
// 渐隐只在内容真的超出列高时启用:短列若也套 mask,会把首尾行洗白
|
|
634
|
+
const [overflow, setOverflow] = React.useState(false)
|
|
635
|
+
|
|
636
|
+
React.useEffect(() => {
|
|
637
|
+
// 子列对齐依赖滚动位置:挂载后同步一次实际值
|
|
638
|
+
props.onScroll(scrollRef.current?.scrollTop ?? 0)
|
|
639
|
+
}, [])
|
|
640
|
+
|
|
641
|
+
React.useEffect(() => {
|
|
642
|
+
const viewport = scrollRef.current
|
|
643
|
+
if (!viewport) return
|
|
644
|
+
const measure = (): void => setOverflow(viewport.scrollHeight > viewport.clientHeight + 8)
|
|
645
|
+
measure()
|
|
646
|
+
const observer = new ResizeObserver(measure)
|
|
647
|
+
observer.observe(viewport)
|
|
648
|
+
return () => observer.disconnect()
|
|
649
|
+
}, [props.items.length, props.canAdd, props.status])
|
|
650
|
+
|
|
651
|
+
return (
|
|
652
|
+
<div className="edrv-qm-col" style={{ left: props.rect.left, top: props.rect.top, height: props.rect.height }} role="menu">
|
|
653
|
+
<div
|
|
654
|
+
className={'edrv-qm-scroll' + (overflow ? ' edrv-qm-scroll--fade' : '')}
|
|
655
|
+
ref={scrollRef}
|
|
656
|
+
onScroll={(event) => props.onScroll(event.currentTarget.scrollTop)}
|
|
657
|
+
>
|
|
658
|
+
{props.status === 'loading' && props.items.length === 0 ? <div className="edrv-qm-empty">正在读取菜单…</div> : null}
|
|
659
|
+
{props.status === 'error' && props.items.length === 0 ? (
|
|
660
|
+
<div className="edrv-qm-empty">
|
|
661
|
+
<div className="edrv-qm-empty-title">菜单没读出来</div>
|
|
662
|
+
<div>{props.error ?? '读取失败'}</div>
|
|
663
|
+
<button type="button" className="edrv-qm-btn" onClick={props.onRetry}>重试</button>
|
|
664
|
+
</div>
|
|
665
|
+
) : null}
|
|
666
|
+
{/* 空态是邀请:给一句说明 + 一个动作,列表里就不再重复「+ 新建」行 */}
|
|
667
|
+
{isEmpty ? (
|
|
668
|
+
<div className="edrv-qm-empty">
|
|
669
|
+
<div className="edrv-qm-empty-title">{props.depth === 0 ? '还没有快捷项' : '这一组是空的'}</div>
|
|
670
|
+
<div>{props.onEmptyHint}</div>
|
|
671
|
+
{props.canAdd ? (
|
|
672
|
+
<button type="button" className="edrv-qm-btn edrv-qm-btn--primary" onClick={props.onAdd}>
|
|
673
|
+
{props.depth === 0 ? '新建第一个' : '新建一项'}
|
|
674
|
+
</button>
|
|
675
|
+
) : null}
|
|
676
|
+
</div>
|
|
677
|
+
) : null}
|
|
678
|
+
<div className="edrv-qm-list">
|
|
679
|
+
{props.items.map((item, index) => {
|
|
680
|
+
// 当前行永远满尺寸:悬停/选中的项比「离中心远近」更该被强调
|
|
681
|
+
const scale = index === props.activeIndex ? { scale: 1, opacity: 1 } : centerScale(index, count)
|
|
682
|
+
const flags = itemStatus(item, props.ctx)
|
|
683
|
+
const badge = flags.errors.length > 0 ? ' edrv-qm-row-badge--error' : ''
|
|
684
|
+
const tip = flags.errors.concat(flags.warnings).join('\n')
|
|
685
|
+
return (
|
|
686
|
+
<div
|
|
687
|
+
key={item.id}
|
|
688
|
+
className={'edrv-qm-row' + (item.type === 'group' ? ' edrv-qm-row--group' : '') + (props.activeIndex === index ? ' edrv-qm-row--active' : '')}
|
|
689
|
+
style={{ transform: 'scale(' + scale.scale.toFixed(3) + ')', opacity: scale.opacity }}
|
|
690
|
+
title={tip}
|
|
691
|
+
data-qm-row="1"
|
|
692
|
+
role="menuitem"
|
|
693
|
+
tabIndex={-1}
|
|
694
|
+
aria-label={item.type === 'group' ? item.name + '(组)' : item.name}
|
|
695
|
+
onPointerEnter={() => props.onHover(index, item)}
|
|
696
|
+
onClick={() => props.onActivate(item)}
|
|
697
|
+
>
|
|
698
|
+
<span className="edrv-qm-row-label">{item.name}</span>
|
|
699
|
+
{badge !== '' ? <span className={'edrv-qm-row-badge' + badge} /> : null}
|
|
700
|
+
{item.type === 'group' ? (
|
|
701
|
+
<span className="edrv-qm-row-chevron" aria-hidden="true">
|
|
702
|
+
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
|
703
|
+
<path d="M4.5 2.5 8 6l-3.5 3.5" />
|
|
704
|
+
</svg>
|
|
705
|
+
</span>
|
|
706
|
+
) : null}
|
|
707
|
+
<button
|
|
708
|
+
type="button"
|
|
709
|
+
className="edrv-qm-row-more"
|
|
710
|
+
title="编辑 / 复制 / 删除"
|
|
711
|
+
aria-label={'「' + item.name + '」的更多操作'}
|
|
712
|
+
onClick={(event) => {
|
|
713
|
+
event.stopPropagation()
|
|
714
|
+
const rowRect: QuickRect = {
|
|
715
|
+
left: props.rect.left,
|
|
716
|
+
top: props.rect.top + COL_PAD + index * ROW_HEIGHT - props.scrollTop,
|
|
717
|
+
width: COL_WIDTH,
|
|
718
|
+
height: ROW_HEIGHT,
|
|
719
|
+
}
|
|
720
|
+
props.onMore(item, rowRect)
|
|
721
|
+
}}
|
|
722
|
+
>⋯</button>
|
|
723
|
+
</div>
|
|
724
|
+
)
|
|
725
|
+
})}
|
|
726
|
+
{props.canAdd && !isEmpty ? (
|
|
727
|
+
// 新建行是动作不是内容:不参与中心缩放,避免被压到 0.7 透明度后看不清
|
|
728
|
+
<div
|
|
729
|
+
className="edrv-qm-row edrv-qm-row--add"
|
|
730
|
+
data-qm-row="1"
|
|
731
|
+
role="menuitem"
|
|
732
|
+
aria-label="新建"
|
|
733
|
+
onClick={props.onAdd}
|
|
734
|
+
>+ 新建</div>
|
|
735
|
+
) : null}
|
|
736
|
+
</div>
|
|
737
|
+
</div>
|
|
738
|
+
{props.onSettings ? (
|
|
739
|
+
<div
|
|
740
|
+
className="edrv-qm-foot"
|
|
741
|
+
onClick={props.onSettings}
|
|
742
|
+
title="显示、点击动作、最大嵌套层数、导入导出"
|
|
743
|
+
role="button"
|
|
744
|
+
tabIndex={0}
|
|
745
|
+
onKeyDown={(event) => {
|
|
746
|
+
if (event.key !== 'Enter' && event.key !== ' ') return
|
|
747
|
+
event.preventDefault()
|
|
748
|
+
props.onSettings?.()
|
|
749
|
+
}}
|
|
750
|
+
>
|
|
751
|
+
<span className="edrv-qm-foot-icon" aria-hidden="true">
|
|
752
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round">
|
|
753
|
+
<circle cx="8" cy="8" r="2.3" />
|
|
754
|
+
<path d="M8 1.9v1.7M8 12.4v1.7M1.9 8h1.7M12.4 8h1.7M3.7 3.7l1.2 1.2M11.1 11.1l1.2 1.2M12.3 3.7l-1.2 1.2M4.9 11.1l-1.2 1.2" />
|
|
755
|
+
</svg>
|
|
756
|
+
</span>
|
|
757
|
+
<span>菜单设置</span>
|
|
758
|
+
</div>
|
|
759
|
+
) : null}
|
|
760
|
+
</div>
|
|
761
|
+
)
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** 行内小菜单 props。 */
|
|
765
|
+
interface RowMenuProps {
|
|
766
|
+
state: RowMenuState
|
|
767
|
+
onEdit(): void
|
|
768
|
+
onDuplicate(): void
|
|
769
|
+
onDelete(): void
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* 行内小菜单(编辑 / 复制 / 删除)。
|
|
774
|
+
* @author ddj 2026年10月30号
|
|
775
|
+
* @param props 状态与动作
|
|
776
|
+
* @returns 小菜单元素
|
|
777
|
+
*/
|
|
778
|
+
function RowMenu(props: RowMenuProps): React.ReactElement {
|
|
779
|
+
return (
|
|
780
|
+
<div
|
|
781
|
+
className="edrv-qm-context"
|
|
782
|
+
style={{ left: props.state.rect.left + COL_WIDTH - 10, top: props.state.rect.top + 26 }}
|
|
783
|
+
data-qm-root="1"
|
|
784
|
+
role="menu"
|
|
785
|
+
>
|
|
786
|
+
<div className="edrv-qm-context-item" role="menuitem" onClick={props.onEdit}>编辑</div>
|
|
787
|
+
<div className="edrv-qm-context-item" role="menuitem" onClick={props.onDuplicate}>复制</div>
|
|
788
|
+
<div className="edrv-qm-context-sep" />
|
|
789
|
+
<div className="edrv-qm-context-item edrv-qm-context-item--danger" role="menuitem" onClick={props.onDelete}>删除</div>
|
|
790
|
+
</div>
|
|
791
|
+
)
|
|
792
|
+
}
|