@objectivex666/dsh-settings-search 1.1.2 → 1.3.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/LICENSE +674 -674
- package/README.en.md +3 -1
- package/README.md +3 -1
- package/lib/client.js +330 -59
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
## Features
|
|
14
14
|
|
|
15
15
|
- 🔍 **Live search** — a search box at the top of the settings panel's left navigation filters every registered settings page (`settings.section`) and general item (`settings.general.item`) as you type.
|
|
16
|
-
-
|
|
16
|
+
- 📂 **In-page option search (v1.2.0)** — search beyond pages and reach the specific options inside them: Plugins-page tabs (`settings.plugins.tab`), Web UI plugin cards (`web-ui.plugin.item`), general rows, and more, each shown with a "page › option" breadcrumb.
|
|
17
|
+
- 🧭 **Click-to-jump** — selecting a result clicks the matching left-nav entry to open its section; for tab options it also opens the tab and flashes the target row. A manual-path hint appears if automatic navigation is not possible.
|
|
18
|
+
- 🌱 **Progressive indexing** — self-drawing settings rows are harvested from the rendered DOM (via their `[data-slot]` anchors) the first time their page is visited, then stay searchable.
|
|
17
19
|
- 🌐 **i18n** — built-in Chinese/English dictionaries that follow the DSH localization system and are easy to extend.
|
|
18
20
|
- 🎨 **Theme-aware** — styled entirely with CSS variables, automatically following the DSH theme (light/dark).
|
|
19
21
|
- ⚡ **Reactive** — subscribes to settings entries being added, removed, or changed, and refreshes results automatically.
|
package/README.md
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
## 功能特性
|
|
14
14
|
|
|
15
15
|
- 🔍 **实时搜索**:在设置面板左侧导航顶部嵌入搜索框,输入关键词立即过滤所有已注册的设置页(`settings.section`)与通用设置项(`settings.general.item`)。
|
|
16
|
-
-
|
|
16
|
+
- 📂 **页内选项搜索(v1.2.0)**:不只搜页面,还能直达每个设置页内部的具体选项——插件页的标签页(`settings.plugins.tab`)、Web UI 插件的卡片(`web-ui.plugin.item`)、通用设置行等,结果以「页面 › 选项」面包屑展示。
|
|
17
|
+
- 🧭 **点击即达**:选中结果后自动点击对应的左侧导航项打开所在分区;若是页内标签页会继续点开对应标签,并高亮闪烁目标选项行。无法自动跳转时显示手动路径提示。
|
|
18
|
+
- 🌱 **渐进索引**:自绘界面的设置行会在其所在页面首次被访问时从渲染 DOM 中收割标题并加入索引(此后始终可搜)。
|
|
17
19
|
- 🌐 **多语言支持**:内置中英文国际化,可跟随 DSH 本地化系统扩展。
|
|
18
20
|
- 🎨 **主题适配**:完全使用 CSS 变量,自动跟随 DSH 主题(亮/暗)。
|
|
19
21
|
- ⚡ **动态响应**:实时订阅设置项的新增/删除/变更,搜索结果自动刷新。
|
package/lib/client.js
CHANGED
|
@@ -7,11 +7,18 @@
|
|
|
7
7
|
* and the client kernel adopts the exported `{ apply, inject }` as the
|
|
8
8
|
* plugin's browser half.
|
|
9
9
|
*
|
|
10
|
-
* Behavior
|
|
11
|
-
*
|
|
12
|
-
* (`settings.header`, priority -1)
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* Behavior:
|
|
11
|
+
* - embeds a live search box at the top of the settings panel's left
|
|
12
|
+
* navigation (`settings.header`, priority -1);
|
|
13
|
+
* - indexes every settings PAGE (`settings.section`);
|
|
14
|
+
* - deep-indexes specific OPTIONS inside pages from machine-readable inner
|
|
15
|
+
* slots (`settings.plugins.tab`, `web-ui.plugin.item`,
|
|
16
|
+
* `settings.general.item`) — occupants that draw their own labels are read
|
|
17
|
+
* back from the rendered DOM via their `[data-slot]` anchors;
|
|
18
|
+
* - results show a breadcrumb path (页面 › 选项);
|
|
19
|
+
* - selecting a result really navigates: clicks the matching left-nav button
|
|
20
|
+
* (then the matching tab button), flashes the target row, and falls back to
|
|
21
|
+
* a breadcrumb hint when no DOM target exists.
|
|
15
22
|
*/
|
|
16
23
|
window.__ModuleLoader__.load({
|
|
17
24
|
id: '@objectivex666/dsh-settings-search',
|
|
@@ -21,28 +28,35 @@ window.__ModuleLoader__.load({
|
|
|
21
28
|
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
22
29
|
const React = require('react')
|
|
23
30
|
|
|
24
|
-
const NS = 'settings-search-
|
|
31
|
+
const NS = 'settings-search-v5'
|
|
25
32
|
const zh = {
|
|
26
33
|
title: '设置',
|
|
27
34
|
placeholder: '搜索设置…',
|
|
28
35
|
empty: '没有匹配的设置',
|
|
29
36
|
pages: '设置页',
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
options: '页内选项',
|
|
38
|
+
page: '页面',
|
|
39
|
+
option: '选项',
|
|
40
|
+
jumpFail: '未能自动跳转,请手动打开:{path}',
|
|
34
41
|
}
|
|
35
42
|
const en = {
|
|
36
43
|
title: 'Settings',
|
|
37
44
|
placeholder: 'Search settings…',
|
|
38
45
|
empty: 'No matching settings',
|
|
39
46
|
pages: 'Settings pages',
|
|
40
|
-
|
|
41
|
-
guide: 'This setting lives in "{label}" — open it from the left navigation',
|
|
47
|
+
options: 'In-page options',
|
|
42
48
|
page: 'Page',
|
|
43
|
-
|
|
49
|
+
option: 'Option',
|
|
50
|
+
jumpFail: 'Could not navigate automatically — open it here: {path}',
|
|
44
51
|
}
|
|
45
52
|
|
|
53
|
+
/** Inner-row slots that represent specific options inside one page. */
|
|
54
|
+
const DEEP_SLOTS = [
|
|
55
|
+
{ slot: 'settings.plugins.tab', parent: 'plugins' },
|
|
56
|
+
{ slot: 'web-ui.plugin.item', parent: 'web-ui-plugins' },
|
|
57
|
+
{ slot: 'settings.general.item', parent: 'general' },
|
|
58
|
+
]
|
|
59
|
+
|
|
46
60
|
const CSS = `
|
|
47
61
|
.sss-wrap { position: relative; display: flex; flex-direction: column; gap: 8px; }
|
|
48
62
|
.sss-title { font-size: 16px; font-weight: 600; line-height: 24px; color: var(--dsw-alias-label-primary); }
|
|
@@ -52,20 +66,24 @@ window.__ModuleLoader__.load({
|
|
|
52
66
|
.sss-input::placeholder { color: var(--dsw-alias-label-dimmed); }
|
|
53
67
|
.sss-clear { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; padding: 0; border: none; border-radius: 4px; background: transparent; color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 1; cursor: pointer; }
|
|
54
68
|
.sss-clear:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
|
|
55
|
-
.sss-pop { position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 20; background: var(--dsw-alias-bg-overlay); border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; box-shadow: var(--dsw-shadow-lv3); padding: 6px; max-height:
|
|
69
|
+
.sss-pop { position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 20; background: var(--dsw-alias-bg-overlay); border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; box-shadow: var(--dsw-shadow-lv3); padding: 6px; max-height: 320px; overflow: auto; }
|
|
56
70
|
.sss-groups { display: flex; flex-direction: column; gap: 8px; }
|
|
57
71
|
.sss-group-title { font-size: 11px; color: var(--dsw-alias-label-secondary); letter-spacing: .05em; padding: 2px 6px; }
|
|
58
72
|
.sss-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
|
|
59
|
-
.sss-item { display: flex; align-items: center; gap:
|
|
73
|
+
.sss-item { display: flex; align-items: center; gap: 8px; padding: 7px 8px; border-radius: 7px; cursor: pointer; }
|
|
60
74
|
.sss-item:hover { background: var(--dsw-alias-interactive-bg-hover); }
|
|
61
|
-
.sss-item-
|
|
62
|
-
.sss-item-
|
|
75
|
+
.sss-item-col { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
|
76
|
+
.sss-item-label { color: var(--dsw-alias-label-primary); font-size: 13px; line-height: 18px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
77
|
+
.sss-item-path { font-size: 10px; line-height: 14px; color: var(--dsw-alias-label-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
78
|
+
.sss-item-kind { flex-shrink: 0; font-size: 10px; color: var(--dsw-alias-label-secondary); border: 1px solid var(--dsw-alias-border-l2); border-radius: 4px; padding: 1px 5px; }
|
|
63
79
|
.sss-empty { margin: 0; padding: 10px 8px; color: var(--dsw-alias-label-secondary); font-size: 13px; }
|
|
64
80
|
.sss-guide { padding: 7px 10px; border-radius: 8px; background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l1); color: var(--dsw-alias-label-primary); font-size: 12px; line-height: 18px; }
|
|
81
|
+
@keyframes sssFlashBg { 0% { background-color: var(--dsw-alias-interactive-bg-hover); } 100% { background-color: transparent; } }
|
|
82
|
+
.sss-flash { animation: sssFlashBg 1.8s ease-out; border-radius: 8px; }
|
|
65
83
|
`
|
|
66
84
|
let stylesInstalled = false
|
|
67
85
|
function installStyles() {
|
|
68
|
-
if (stylesInstalled || typeof document === 'undefined') return
|
|
86
|
+
if (stylesInstalled || typeof document === 'undefined') return undefined
|
|
69
87
|
stylesInstalled = true
|
|
70
88
|
const tag = document.createElement('style')
|
|
71
89
|
tag.dataset.plugin = '@objectivex666/dsh-settings-search'
|
|
@@ -78,10 +96,230 @@ window.__ModuleLoader__.load({
|
|
|
78
96
|
}
|
|
79
97
|
}
|
|
80
98
|
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
99
|
+
const callSafe = (fn) => {
|
|
100
|
+
try { return fn() } catch { return undefined }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Resolve a registrant-provided label option (string or thunk). */
|
|
104
|
+
const optionLabel = (raw) => {
|
|
105
|
+
const value = typeof raw === 'function' ? callSafe(raw) : raw
|
|
106
|
+
return typeof value === 'string' ? value : ''
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function createModel(ctx) {
|
|
110
|
+
/** id → harvested visible title, per slot. */
|
|
111
|
+
const harvest = new Map()
|
|
112
|
+
let harvestSeq = 0
|
|
113
|
+
|
|
114
|
+
const entriesOf = (slot) => {
|
|
115
|
+
try { return ctx.slots.entries(slot) ?? [] } catch { return [] }
|
|
116
|
+
}
|
|
117
|
+
const sortEntries = (list) => list.slice().sort((a, b) => ((a.options?.order ?? 0) - (b.options?.order ?? 0)))
|
|
118
|
+
|
|
119
|
+
/** Best-effort static label from occupant options/locale namespace. */
|
|
120
|
+
const staticLabel = (entry, slotConfig) => {
|
|
121
|
+
const o = entry.options ?? {}
|
|
122
|
+
const explicit = optionLabel(o.label)
|
|
123
|
+
if (explicit !== '') return explicit
|
|
124
|
+
if (typeof o.locale === 'string') {
|
|
125
|
+
const t = callSafe(() => ctx.locale.bind(o.locale))
|
|
126
|
+
if (typeof t === 'function') {
|
|
127
|
+
for (const key of ['tab', 'title', 'card.title', 'name']) {
|
|
128
|
+
const value = callSafe(() => t(key))
|
|
129
|
+
if (typeof value === 'string' && value !== '' && value !== key) return value
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
void slotConfig
|
|
134
|
+
return ''
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Read visible titles for self-drawing occupants out of the live DOM.
|
|
139
|
+
* Two shell shapes exist for `[data-slot="<name>"]`:
|
|
140
|
+
* - per-occupant anchors: one element each, count === regs → pair ids;
|
|
141
|
+
* - a single WRAPPER around all rows (e.g. settings.general.item's
|
|
142
|
+
* stacked section): harvest each direct child as one row, keyed by
|
|
143
|
+
* render order ("#i") since id pairing is impossible.
|
|
144
|
+
*/
|
|
145
|
+
const runHarvest = () => {
|
|
146
|
+
let grew = false
|
|
147
|
+
if (typeof document === 'undefined') return false
|
|
148
|
+
let map
|
|
149
|
+
for (const cfg of DEEP_SLOTS) {
|
|
150
|
+
let containers = []
|
|
151
|
+
try { containers = [...document.querySelectorAll(`[data-slot="${cfg.slot}"]`)] } catch { continue }
|
|
152
|
+
const regs = sortEntries(entriesOf(cfg.slot))
|
|
153
|
+
if (regs.length === 0) continue
|
|
154
|
+
let nodes = []
|
|
155
|
+
if (containers.length === 1 && containers[0].childElementCount > 0) {
|
|
156
|
+
nodes = [...containers[0].children]
|
|
157
|
+
} else {
|
|
158
|
+
nodes = containers.filter((n) => n.childElementCount >= 0)
|
|
159
|
+
}
|
|
160
|
+
if (nodes.length === 0) continue
|
|
161
|
+
map = harvest.get(cfg.slot)
|
|
162
|
+
if (map === undefined) { map = new Map(); harvest.set(cfg.slot, map) }
|
|
163
|
+
if (nodes.length !== regs.length) {
|
|
164
|
+
// wrapper shape: index-keyed loose harvest of every rendered row
|
|
165
|
+
nodes.forEach((node, i) => {
|
|
166
|
+
const key = '#' + i
|
|
167
|
+
if (map.has(key)) return
|
|
168
|
+
const title = titleFromNode(node)
|
|
169
|
+
if (title === '') return
|
|
170
|
+
const text = (node.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 160)
|
|
171
|
+
map.set(key, { title, idx: i, text }); grew = true
|
|
172
|
+
})
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
regs.forEach((entry, i) => {
|
|
176
|
+
const id = String(entry.options?.id ?? i)
|
|
177
|
+
if (map.has(id)) return
|
|
178
|
+
const title = titleFromNode(nodes[i])
|
|
179
|
+
if (title === '') return
|
|
180
|
+
const text = (nodes[i].textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 160)
|
|
181
|
+
map.set(id, { title, idx: i, text }); grew = true
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
if (grew) harvestSeq += 1
|
|
185
|
+
return grew
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const titleFromNode = (node) => {
|
|
189
|
+
const pick = (el) => {
|
|
190
|
+
const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim()
|
|
191
|
+
return text !== '' && text.length <= 60 ? text : ''
|
|
192
|
+
}
|
|
193
|
+
for (const sel of ['h1,h2,h3,h4,h5', '[class*="title"]', '[class*="heading"]', 'label', 'p']) {
|
|
194
|
+
const hit = node.querySelector(sel)
|
|
195
|
+
if (hit) { const text = pick(hit); if (text !== '') return text }
|
|
196
|
+
}
|
|
197
|
+
const first = node.firstElementChild
|
|
198
|
+
if (first) {
|
|
199
|
+
for (const child of first.children) { const text = pick(child); if (text !== '') return text }
|
|
200
|
+
}
|
|
201
|
+
return pick(node).split('·')[0].trim()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const readSections = () => sortEntries(entriesOf('settings.section'))
|
|
205
|
+
.filter((e) => e.options?.id !== 'settings-search')
|
|
206
|
+
.map((e) => ({
|
|
207
|
+
kind: 'page',
|
|
208
|
+
id: String(e.options?.id ?? ''),
|
|
209
|
+
order: e.options?.order ?? 0,
|
|
210
|
+
label: optionLabel(e.options?.label),
|
|
211
|
+
}))
|
|
212
|
+
.filter((e) => e.id !== '' && e.label !== '')
|
|
213
|
+
|
|
214
|
+
const readDeep = () => {
|
|
215
|
+
const sections = readSections()
|
|
216
|
+
const parentLabel = (parentId) =>
|
|
217
|
+
sections.find((s) => s.id === parentId)?.label ?? parentId
|
|
218
|
+
const out = []
|
|
219
|
+
for (const cfg of DEEP_SLOTS) {
|
|
220
|
+
const mapped = harvest.get(cfg.slot)
|
|
221
|
+
sortEntries(entriesOf(cfg.slot)).forEach((entry, i) => {
|
|
222
|
+
const id = String(entry.options?.id ?? '')
|
|
223
|
+
if (id === '') return
|
|
224
|
+
// static label first, then id-paired harvest, then loose index-keyed harvest
|
|
225
|
+
const hitId = mapped?.get(id)
|
|
226
|
+
const hitIdx = mapped?.get('#' + i)
|
|
227
|
+
const hitObj = typeof hitId === 'object' ? hitId : (typeof hitIdx === 'object' ? hitIdx : undefined)
|
|
228
|
+
const label = staticLabel(entry, cfg) || hitObj?.title || ''
|
|
229
|
+
if (label === '') return // unmapped, unharvested — invisible until it renders once
|
|
230
|
+
const key = cfg.slot + '#' + id
|
|
231
|
+
if (out.some((e) => e.key === key)) return
|
|
232
|
+
out.push({
|
|
233
|
+
kind: 'option',
|
|
234
|
+
key,
|
|
235
|
+
slot: cfg.slot,
|
|
236
|
+
id,
|
|
237
|
+
order: entry.options?.order ?? 0,
|
|
238
|
+
label,
|
|
239
|
+
hay: label + ' ' + (hitObj?.text ?? ''),
|
|
240
|
+
rowIndex: hitObj?.idx ?? i,
|
|
241
|
+
parentId: cfg.parent,
|
|
242
|
+
parentLabel: parentLabel(cfg.parent),
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
return out.sort((a, b) => a.parentLabel !== b.parentLabel
|
|
247
|
+
? a.parentLabel.localeCompare(b.parentLabel)
|
|
248
|
+
: a.order - b.order)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return { readSections, readDeep, runHarvest, bump: () => harvestSeq }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/* ---------- DOM-level navigation into the settings shell ---------- */
|
|
255
|
+
|
|
256
|
+
const panelButtons = () => {
|
|
257
|
+
if (typeof document === 'undefined') return []
|
|
258
|
+
const root = document.querySelector('[role="dialog"]') ?? document.body
|
|
259
|
+
return [...root.querySelectorAll('button')]
|
|
260
|
+
}
|
|
261
|
+
const clickNavButton = (wantText) => {
|
|
262
|
+
const want = String(wantText ?? '').trim()
|
|
263
|
+
if (want === '') return false
|
|
264
|
+
const buttons = panelButtons()
|
|
265
|
+
const textOf = (b) => (b.textContent ?? '').trim()
|
|
266
|
+
const hit = buttons.find((b) => textOf(b) === want)
|
|
267
|
+
?? buttons.find((b) => textOf(b).startsWith(want))
|
|
268
|
+
?? buttons.find((b) => want.length >= 2 && textOf(b) !== '' && (textOf(b).includes(want) || want.includes(textOf(b))))
|
|
269
|
+
if (!hit) return false
|
|
270
|
+
hit.click()
|
|
271
|
+
return true
|
|
272
|
+
}
|
|
273
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
274
|
+
|
|
275
|
+
function createNavigator(ctx) {
|
|
276
|
+
/** Jump to a page result; falls back to showing a manual hint. */
|
|
277
|
+
const jumpToPage = async (item) => {
|
|
278
|
+
const ok = await Promise.resolve(clickNavButton(item.label))
|
|
279
|
+
return ok
|
|
280
|
+
}
|
|
281
|
+
/** Jump to an in-page option: open its page, select its tab, flash its row. */
|
|
282
|
+
const jumpToOption = async (item) => {
|
|
283
|
+
const opened = await Promise.resolve(clickNavButton(item.parentLabel))
|
|
284
|
+
await delay(160)
|
|
285
|
+
const label = item.label
|
|
286
|
+
const buttons = panelButtons()
|
|
287
|
+
const tab = buttons.find((b) => (b.textContent ?? '').trim() === label)
|
|
288
|
+
?? buttons.find((b) => (b.getAttribute('role') ?? '') === 'tab' && (b.textContent ?? '').includes(label))
|
|
289
|
+
if (tab) { tab.click(); await delay(120) }
|
|
290
|
+
flashOptionRow(item)
|
|
291
|
+
return opened
|
|
292
|
+
}
|
|
293
|
+
/** Highlight the rendered row card for one indexed option. */
|
|
294
|
+
const flashOptionRow = (item) => {
|
|
295
|
+
try {
|
|
296
|
+
let target = null
|
|
297
|
+
if (typeof item.rowIndex === 'number') {
|
|
298
|
+
// wrapper shape: pick the exact rendered row by index
|
|
299
|
+
const container = document.querySelector(`[data-slot="${item.slot}"]`)
|
|
300
|
+
target = container?.children?.[item.rowIndex] ?? null
|
|
301
|
+
if (target && !(target.textContent ?? '').includes(item.label)) {
|
|
302
|
+
// index drift — fall through to textual match below
|
|
303
|
+
target = null
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (!target) {
|
|
307
|
+
const nodes = [...document.querySelectorAll(`[data-slot="${item.slot}"]`)]
|
|
308
|
+
const rows = nodes.length === 1 && nodes[0].childElementCount > 0
|
|
309
|
+
? [...nodes[0].children]
|
|
310
|
+
: nodes
|
|
311
|
+
target = rows.find((n) => (n.textContent ?? '').includes(item.label)) ?? null
|
|
312
|
+
}
|
|
313
|
+
if (!target) return
|
|
314
|
+
target.scrollIntoView({ block: 'nearest' })
|
|
315
|
+
target.classList.remove('sss-flash')
|
|
316
|
+
void target.offsetWidth // restart animation if re-flashed
|
|
317
|
+
target.classList.add('sss-flash')
|
|
318
|
+
setTimeout(() => target.classList.remove('sss-flash'), 1900)
|
|
319
|
+
} catch { /* visual nicety only */ }
|
|
320
|
+
}
|
|
321
|
+
void ctx
|
|
322
|
+
return { jumpToPage, jumpToOption }
|
|
85
323
|
}
|
|
86
324
|
|
|
87
325
|
function apply(ctx) {
|
|
@@ -92,13 +330,8 @@ window.__ModuleLoader__.load({
|
|
|
92
330
|
return () => { if (typeof remove === 'function') remove() }
|
|
93
331
|
}, 'settings-search: styles')
|
|
94
332
|
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
.map((e) => ({ id: e.options.id, order: e.options.order ?? 0, label: resolveLabel(e) }))
|
|
98
|
-
.sort((a, b) => a.order - b.order)
|
|
99
|
-
const readRows = () => ctx.slots.entries('settings.general.item')
|
|
100
|
-
.map((e) => ({ id: e.options.id, order: e.options.order ?? 0, label: resolveLabel(e) }))
|
|
101
|
-
.sort((a, b) => a.order - b.order)
|
|
333
|
+
const model = createModel(ctx)
|
|
334
|
+
const navigator = createNavigator(ctx)
|
|
102
335
|
|
|
103
336
|
const SearchIcon = React.createElement('svg', {
|
|
104
337
|
className: 'sss-icon',
|
|
@@ -121,36 +354,74 @@ window.__ModuleLoader__.load({
|
|
|
121
354
|
function SettingsHeaderSearch() {
|
|
122
355
|
const [query, setQuery] = React.useState('')
|
|
123
356
|
const [open, setOpen] = React.useState(false)
|
|
124
|
-
const [
|
|
125
|
-
const [
|
|
126
|
-
|
|
357
|
+
const [tick, setTick] = React.useState(0)
|
|
358
|
+
const [, forceTick] = React.useReducer((x) => x + 1, 0)
|
|
359
|
+
void tick
|
|
360
|
+
|
|
127
361
|
React.useEffect(() => {
|
|
128
|
-
const
|
|
129
|
-
setSections(readSections())
|
|
130
|
-
setRows(readRows())
|
|
131
|
-
}
|
|
362
|
+
const watched = ['settings.section', ...DEEP_SLOTS.map((c) => c.slot)]
|
|
132
363
|
const offs = [
|
|
133
|
-
ctx.slots.subscribe(
|
|
134
|
-
ctx.
|
|
135
|
-
ctx.locale.subscribe(refresh),
|
|
364
|
+
...watched.map((slot) => ctx.slots.subscribe(slot, forceTick)),
|
|
365
|
+
ctx.locale.subscribe(forceTick),
|
|
136
366
|
]
|
|
137
|
-
|
|
367
|
+
// Grow the index whenever the panel body re-renders (visits new pages).
|
|
368
|
+
const observer = typeof MutationObserver === 'function'
|
|
369
|
+
? new MutationObserver(() => {
|
|
370
|
+
if (model.runHarvest()) setTick((x) => x + 1)
|
|
371
|
+
})
|
|
372
|
+
: null
|
|
373
|
+
observer?.observe(document.body, { childList: true, subtree: true })
|
|
374
|
+
const warm = setTimeout(() => { model.runHarvest(); setTick((x) => x + 1) }, 800)
|
|
375
|
+
return () => {
|
|
376
|
+
for (const off of offs) off()
|
|
377
|
+
observer?.disconnect()
|
|
378
|
+
clearTimeout(warm)
|
|
379
|
+
}
|
|
138
380
|
}, [])
|
|
139
381
|
|
|
140
382
|
const q = query.trim().toLowerCase()
|
|
141
383
|
const tokens = q.split(/\s+/).filter(Boolean)
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
const
|
|
384
|
+
const matches = (haystack) => tokens.every((tok) => haystack.includes(tok))
|
|
385
|
+
|
|
386
|
+
const sections = model.readSections()
|
|
387
|
+
const deeps = q !== '' ? model.readDeep() : []
|
|
388
|
+
const shownPages = q !== ''
|
|
389
|
+
? sections.filter((s) => matches(`${s.label} ${s.id}`.toLowerCase()))
|
|
390
|
+
: []
|
|
391
|
+
const shownOptions = q !== ''
|
|
392
|
+
? deeps.filter((o) => matches(`${o.hay} ${o.parentLabel} ${o.id} ${o.slot}`.toLowerCase()))
|
|
393
|
+
: []
|
|
394
|
+
const none = q !== '' && shownPages.length === 0 && shownOptions.length === 0
|
|
146
395
|
|
|
147
|
-
const
|
|
148
|
-
|
|
396
|
+
const [hint, setHint] = React.useState(null)
|
|
397
|
+
const pick = (item) => {
|
|
398
|
+
setOpen(false)
|
|
399
|
+
const jumped = item.kind === 'page'
|
|
400
|
+
? navigator.jumpToPage(item)
|
|
401
|
+
: navigator.jumpToOption(item)
|
|
402
|
+
Promise.resolve(jumped).then((ok) => {
|
|
403
|
+
if (ok === false) setHint(item.kind === 'page' ? item.label : `${item.parentLabel} › ${item.label}`)
|
|
404
|
+
})
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const renderPage = (item) => React.createElement('li', {
|
|
408
|
+
key: 'page:' + item.id,
|
|
149
409
|
className: 'sss-item',
|
|
150
|
-
onMouseDown: () =>
|
|
410
|
+
onMouseDown: () => pick(item),
|
|
151
411
|
}, [
|
|
152
|
-
React.createElement('span', { className: 'sss-item-label' }, item.label
|
|
153
|
-
React.createElement('span', { className: 'sss-item-
|
|
412
|
+
React.createElement('span', { className: 'sss-item-label' }, item.label),
|
|
413
|
+
React.createElement('span', { className: 'sss-item-kind' }, t('page')),
|
|
414
|
+
])
|
|
415
|
+
const renderOption = (item) => React.createElement('li', {
|
|
416
|
+
key: item.key,
|
|
417
|
+
className: 'sss-item',
|
|
418
|
+
onMouseDown: () => pick(item),
|
|
419
|
+
}, [
|
|
420
|
+
React.createElement('span', { className: 'sss-item-col' }, [
|
|
421
|
+
React.createElement('span', { className: 'sss-item-label' }, item.label),
|
|
422
|
+
React.createElement('span', { className: 'sss-item-path' }, item.parentLabel),
|
|
423
|
+
]),
|
|
424
|
+
React.createElement('span', { className: 'sss-item-kind' }, t('option')),
|
|
154
425
|
])
|
|
155
426
|
|
|
156
427
|
return React.createElement('div', { className: 'sss-wrap' }, [
|
|
@@ -162,32 +433,32 @@ window.__ModuleLoader__.load({
|
|
|
162
433
|
value: query,
|
|
163
434
|
placeholder: t('placeholder'),
|
|
164
435
|
spellCheck: false,
|
|
165
|
-
onChange: (e) => { setQuery(e.target.value); setOpen(true) },
|
|
436
|
+
onChange: (e) => { setQuery(e.target.value); setHint(null); setOpen(true) },
|
|
166
437
|
onFocus: () => setOpen(true),
|
|
167
438
|
onBlur: () => setOpen(false),
|
|
168
439
|
}),
|
|
169
440
|
query !== '' ? React.createElement('button', {
|
|
170
441
|
className: 'sss-clear',
|
|
171
442
|
type: 'button',
|
|
172
|
-
onClick: () => { setQuery('');
|
|
443
|
+
onClick: () => { setQuery(''); setHint(null); setOpen(false) },
|
|
173
444
|
}, '✕') : null,
|
|
174
445
|
]),
|
|
175
|
-
open &&
|
|
446
|
+
open && q !== '' ? React.createElement('div', { className: 'sss-pop' }, [
|
|
176
447
|
none
|
|
177
448
|
? React.createElement('p', { className: 'sss-empty' }, t('empty'))
|
|
178
449
|
: React.createElement('div', { className: 'sss-groups' }, [
|
|
179
|
-
|
|
450
|
+
shownPages.length > 0 ? React.createElement('div', { className: 'sss-group' }, [
|
|
180
451
|
React.createElement('div', { className: 'sss-group-title' }, t('pages')),
|
|
181
|
-
React.createElement('ul', { className: 'sss-list' },
|
|
452
|
+
React.createElement('ul', { className: 'sss-list' }, shownPages.map(renderPage)),
|
|
182
453
|
]) : null,
|
|
183
|
-
|
|
184
|
-
React.createElement('div', { className: 'sss-group-title' }, t('
|
|
185
|
-
React.createElement('ul', { className: 'sss-list' },
|
|
454
|
+
shownOptions.length > 0 ? React.createElement('div', { className: 'sss-group' }, [
|
|
455
|
+
React.createElement('div', { className: 'sss-group-title' }, t('options')),
|
|
456
|
+
React.createElement('ul', { className: 'sss-list' }, shownOptions.map(renderOption)),
|
|
186
457
|
]) : null,
|
|
187
458
|
]),
|
|
188
459
|
]) : null,
|
|
189
|
-
|
|
190
|
-
? React.createElement('div', { className: 'sss-guide' }, t('
|
|
460
|
+
hint !== null
|
|
461
|
+
? React.createElement('div', { className: 'sss-guide' }, t('jumpFail').replace('{path}', hint))
|
|
191
462
|
: null,
|
|
192
463
|
])
|
|
193
464
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectivex666/dsh-settings-search",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "DSH settings panel search plugin – instantly filter setting sections and general items with navigation guidance. Installable via `dsh plugin add`.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|