dsh-quick-toc 0.3.3 → 0.4.1
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.en.md +27 -0
- package/CHANGELOG.md +27 -0
- package/README.en.md +23 -19
- package/README.md +23 -19
- package/lib/client.js +599 -55
- package/package.json +4 -3
package/CHANGELOG.en.md
CHANGED
|
@@ -2,6 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to **dsh-quick-toc** are documented here. Chinese version: [CHANGELOG.md](CHANGELOG.md).
|
|
4
4
|
|
|
5
|
+
## [0.4.0] - 2026-09-11
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Search result list**: while a query is present the outline becomes a result list — one row per match, showing the matched heading or message with the keyword highlighted, its heading path, the turn time and a repeat count (plus a context snippet in full-text scope). Rows are chronological with the **newest hit at the bottom**, a fresh search starts at the newest hit and you scroll up to reach earlier ones; **clicking a row** makes that hit current, i.e. the same path as Enter stepping — it highlights the keyword in the conversation and scrolls there. `n/N` Enter-stepping is kept.
|
|
9
|
+
- **Sticky group headers**: while scrolling the outline, the header of the turn you are inside stays pinned to the top of the panel, flush against the toolbar above it.
|
|
10
|
+
- **Collapsible level-filter row**: the **层级 (levels)** button at the left of the magnifier hides/shows the `1`–`6` row (the choice is remembered; the filter keeps applying while it is hidden).
|
|
11
|
+
- **Position breadcrumb**: the top of the panel shows the path of the section you are reading; click it to jump there. The rule is "the deepest heading above the viewport's middle line" — the section that fills most of the screen — and since a jump also lands above that line, the breadcrumb still shows the same heading after you click it.
|
|
12
|
+
- **Heading level filter**: the `1`–`6` chips at the top of the panel are independent switches, so any combination works (e.g. H1 and H3 with H2 hidden) and the choice is remembered; switching off the last remaining level restores all six.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- **Performance**: heading parsing and text extraction are now cached per node, so a streaming update only re-processes the node that changed instead of the whole history; the cache drops nodes that left the conversation, keeping it bounded.
|
|
16
|
+
- The turn header (the time row) now jumps to the **start of that turn's model reply** (it used to jump to the user message).
|
|
17
|
+
- **Compatibility**: verified against and declared for DSH **0.1.5-rc.1 and 0.1.5-rc.2** (`engines.dsh` keeps its floor of `>=0.1.5-rc.1`). The host interface is identical in both — the session-scoped slot `conversation.input.overlay`, the `useChat` hook from `dsh-client-ui-chat` and the client-module seed table were all checked against the installed bundles.
|
|
18
|
+
- `countOccurrences` now guards an empty needle (an empty query made `indexOf` spin in place — an infinite loop; every current call site is guarded, so this is hardening).
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- The outline did not jump to the newest turn when content first appeared: that effect's dependency array was written above the `groups` declaration, so it always evaluated to `0` during render and the effect only ever ran on mount. The effect now sits below `groups` and depends on `groups.length`.
|
|
22
|
+
- Dragging the top bar to move the panel persisted the position from **before** the drag (the closure kept the value captured at pointer-down), so the panel jumped back to its old spot after a reload. It now stores the position the drag ended at.
|
|
23
|
+
- When the same keyword occurred more than once inside one text node, the in-chat highlight wrapped only the FIRST occurrence there, so the later one was neither tinted nor eligible to be marked as the current hit — stepping to it found no current mark and degraded to a plain scroll with no distinct highlight. Every occurrence inside each text node is now wrapped in order, so the in-conversation hit order lines up with `n/N`.
|
|
24
|
+
- `# comment` / `## example` lines inside a ``` fenced code block were treated as headings and leaked into the outline: they have no element to jump to, and they shifted the index of the real headings after them so those jumped to the wrong place. Heading parsing now skips fenced blocks (``` and ~~~, info strings, longer closing fences, up to 3 spaces of indentation).
|
|
25
|
+
|
|
26
|
+
## [0.4.1] - 2026-09-12
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
- **Heading levels are now a popup**: a round icon button in the header (three lines of decreasing width, hover feedback matching the magnifier) scales the H1–H6 picker out of the **button's center**, right-aligned with an outer shadow; closing shrinks it back into the button with a fade. The picker no longer occupies its own row, the chips are labelled H1–H6, and their selected tint matches the title / full-text toggle.
|
|
30
|
+
- Removed the "you are here" breadcrumb row introduced in 0.4.0 (it felt unnecessary in use).
|
|
31
|
+
|
|
5
32
|
## [0.3.3] - 2026-09-10
|
|
6
33
|
|
|
7
34
|
### Added
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
**dsh-quick-toc** 的重要变更都记录在这里。英文版见 [CHANGELOG.en.md](CHANGELOG.en.md)。
|
|
4
4
|
|
|
5
|
+
## [0.4.0] - 2026-09-11
|
|
6
|
+
|
|
7
|
+
### 新增
|
|
8
|
+
- **搜索结果列表**:有搜索词时大纲切换为结果列表,逐条列出命中(标题关键字高亮、层级路径、回合时间、重复次数,全文模式附上下文片段)。列表按时间排列、**最新的命中在底部**,开始搜索时自动定位到最新命中,向上滚回看更早的命中;点击任一行会把该命中设为当前命中——和回车逐处跳同一条路径,会在对话里高亮并滚过去;`n/N` 回车逐处跳保留
|
|
9
|
+
- **组头吸顶**:滚动大纲时,当前所在回合的组头固定在面板顶部,并与上方工具条紧贴
|
|
10
|
+
- **层级筛选行可收起**:标题栏放大镜左边的 **层级** 按钮收起/展开 `1`–`6` 那一行(选择会记住;收起后筛选设置仍然生效)
|
|
11
|
+
- **位置面包屑**:面板顶部显示你正在读的章节路径,点击可跳到该标题。判定规则是"视口中线之上最深的标题"(画面里占得最多的那一节);跳转落点也在中线之上,所以点完后面包屑显示的仍是同一个标题
|
|
12
|
+
- **标题层级筛选**:面板顶部 `1`–`6` 芯片是各自独立的开关,可任意组合(例如只留 H1 与 H3、关掉中间的 H2),选择会记住;把最后一个亮着的层级也关掉时自动恢复显示全部六级
|
|
13
|
+
|
|
14
|
+
### 变更
|
|
15
|
+
- **性能**:标题解析与文本提取改为按节点缓存——流式更新只重算真正变化的节点,不再每次重算整段历史;缓存会清理已离开会话的节点,保持有界
|
|
16
|
+
- 回合组头(时间那一行)点击改为跳到**该回合模型回答的开头**(原来跳到用户消息位置)
|
|
17
|
+
- **兼容性**:在 DSH **0.1.5-rc.1 与 0.1.5-rc.2** 上验证并声明兼容(`engines.dsh` 下限仍为 `>=0.1.5-rc.1`)。两版的宿主接口一致——会话级槽位 `conversation.input.overlay`、`dsh-client-ui-chat` 的 `useChat` hook 与客户端模块 seed 表都经实测核对相同。
|
|
18
|
+
- `countOccurrences` 增加空查询守卫(空查询会让 `indexOf` 原地打转导致死循环;现有调用点都有守卫,此举为加固)
|
|
19
|
+
|
|
20
|
+
### 修复
|
|
21
|
+
- 大纲首次载入内容时没有自动定位到最新回合:该副作用的依赖数组写在 `groups` 声明之前,渲染时读到的永远是 `0`,导致副作用只在挂载时执行一次。已把副作用移到 `groups` 之后并直接依赖 `groups.length`。
|
|
22
|
+
- 拖动顶部横条调整面板垂直位置后,记录的仍是**拖动前**的位置(写入的是拖动开始时闭包里的旧值),刷新后面板跳回原位。现在写入拖动结束时的实际位置。
|
|
23
|
+
- 同一段文本里同一关键字出现多次时,对话内高亮只包住**该文本节点里的第一处**:第二处既不染色,也无法被标成"当前命中",于是跳到第二处时找不到当前标记,退化成"只滚动、没有高亮"。现在每个文本节点内的所有出现都会依次包住,对话内命中序号与 `n/N` 完全对齐。
|
|
24
|
+
- ``` 围栏代码块里的 `# 注释` / `## 示例` 会被当成标题混进大纲(既没有可跳转的目标,又会让它后面那些真标题的序号错位、跳到错误位置)。现在解析标题时会跳过围栏代码块(支持 ``` 与 ~~~、带 info string 的开栏、更长的闭栏、最多 3 空格缩进)。
|
|
25
|
+
|
|
26
|
+
## [0.4.1] - 2026-09-12
|
|
27
|
+
|
|
28
|
+
### 变更
|
|
29
|
+
- **层级筛选改为弹出选择**:标题栏新增圆形图标按钮(三条递减横线,悬停反馈与放大镜一致),点击后从**按钮中心**放大弹出 H1–H6 选择层(靠右、带外阴影),收回时缩回按钮并渐隐,不再单独占一行;选择芯片标注 H1–H6,选中态配色与搜索的"标题 / 全文"切换一致
|
|
30
|
+
- 移除面板顶部的"当前位置"面包屑行(0.4.0 新增,使用中显得多余)
|
|
31
|
+
|
|
5
32
|
## [0.3.3] - 2026-09-10
|
|
6
33
|
|
|
7
34
|
### 新增
|
package/README.en.md
CHANGED
|
@@ -6,26 +6,29 @@ A conversation TOC plugin for [DeepSeek Harness](https://github.com/deepseek-ai/
|
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
|
-
- **Turn-grouped outline** — each user message plus its following AI replies form one group; the group header shows the turn's
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
13
|
-
- **
|
|
14
|
-
- **
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
19
|
-
-
|
|
9
|
+
- **Turn-grouped outline** — each user message plus its following AI replies form one group; the group header shows the turn's time and a first-line preview, and clicking it jumps to the start of that turn's model reply
|
|
10
|
+
- **Search** — the magnifier in the panel header opens a search box: title / full-text scopes, click any result row to locate and highlight that hit, `n/N` Enter stepping, Esc to close
|
|
11
|
+
- **In-chat highlighting** — matched keywords are highlighted in the conversation; the current match is highlighted distinctly
|
|
12
|
+
- **Sticky group headers** — while scrolling the outline, the current turn's header stays pinned at the top of the panel
|
|
13
|
+
- **Heading level filter** — the round levels button in the header pops down H1–H6 switches for any combination
|
|
14
|
+
- **Auto-follow** — the turn being read lights up in the outline while you scroll the conversation; the outline loads and follows on its own
|
|
15
|
+
- **Jumping** — click a heading to jump to its position in the conversation
|
|
16
|
+
- **Dockable and resizable** — drag the top bar to move the panel, ◀ / ▶ to dock left or right, drag an edge to resize, and collapse it into an edge handle; position and size are remembered
|
|
17
|
+
- **Paging** — the most recent groups show first; scroll up to load older turns
|
|
18
|
+
- **Markdown-aware** — inline markup in headings is stripped; `#` lines inside fenced code blocks are not headings
|
|
19
|
+
- **Theme-aware** — adapts to dark and light themes; visible on the chat view only and fades out elsewhere
|
|
20
|
+
- The panel hides itself when the conversation has neither headings nor readable times
|
|
20
21
|
|
|
21
22
|
## Compatibility
|
|
22
23
|
|
|
23
24
|
| Plugin | Supported DSH |
|
|
24
25
|
| --- | --- |
|
|
25
|
-
| **0.
|
|
26
|
-
| 0.
|
|
26
|
+
| **0.4.1** (latest) | 0.1.5-rc.1, 0.1.5-rc.2 |
|
|
27
|
+
| 0.4.0 | 0.1.5-rc.1, 0.1.5-rc.2 |
|
|
28
|
+
| 0.3.3 | 0.1.5-rc.1 |
|
|
29
|
+
| 0.3.0 – 0.3.2 | ≥ 0.1.2-rc.1 |
|
|
27
30
|
|
|
28
|
-
|
|
31
|
+
`engines.dsh` has a floor of **0.1.5-rc.1** (`>=`): from that release on, the plugin uses the host's session-scoped slot injection. Since 0.4.0 the plugin has been verified against **0.1.5-rc.1 and 0.1.5-rc.2** and declares exactly those; older or newer DSH versions are unverified and therefore not claimed. On an older DSH, the newest usable plugin version is **0.3.2**. On install or update, the DSH market pre-flights host compatibility from `engines.dsh`, `dsh.compatibility.dshReleases` and `peerDependencies` in `package.json`.
|
|
29
32
|
|
|
30
33
|
## Install
|
|
31
34
|
|
|
@@ -51,11 +54,12 @@ After installing, restart DSH and open the Web UI. The panel starts collapsed; c
|
|
|
51
54
|
|
|
52
55
|
## Usage
|
|
53
56
|
|
|
54
|
-
- **Jumping**: click
|
|
55
|
-
- **Search**:
|
|
56
|
-
- **
|
|
57
|
-
- **
|
|
58
|
-
- **
|
|
57
|
+
- **Jumping**: click an outline heading or a group header to jump to it
|
|
58
|
+
- **Search**: open the box with the magnifier, click a result to locate and highlight it, Enter to step through matches, Esc to close
|
|
59
|
+
- **Level filter**: click the round levels button in the header to pop down the H1–H6 switches
|
|
60
|
+
- **Moving and docking**: drag the top bar to move, ◀ / ▶ to switch sides
|
|
61
|
+
- **Resizing**: drag the right edge, bottom edge or bottom-right corner
|
|
62
|
+
- **Loading older turns**: scroll up inside the outline
|
|
59
63
|
|
|
60
64
|
## Development
|
|
61
65
|
|
package/README.md
CHANGED
|
@@ -6,26 +6,29 @@
|
|
|
6
6
|
|
|
7
7
|
## 功能
|
|
8
8
|
|
|
9
|
-
- **按回合分组** —— 每条用户消息 + 其后续 AI
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
9
|
+
- **按回合分组** —— 每条用户消息 + 其后续 AI 回复为一组,组头显示回合时间与首行预览,点击跳到该回合模型回答的开头
|
|
10
|
+
- **搜索** —— 标题栏放大镜打开搜索框:标题 / 全文两种范围,结果列表点击任一条定位并高亮,`n/N` 回车逐处跳,Esc 关闭
|
|
11
|
+
- **对话内高亮** —— 命中的关键字在对话中高亮,当前命中单独标亮
|
|
12
|
+
- **组头吸顶** —— 滚动大纲时,当前回合的组头固定在面板顶部
|
|
13
|
+
- **层级筛选** —— 标题栏的圆形层级按钮弹出 H1–H6 开关,任意组合显示
|
|
14
|
+
- **自动跟随** —— 滚动对话时正在阅读的回合在大纲中自动点亮,其余变暗;大纲自动加载并跟随
|
|
15
|
+
- **跳转** —— 点击标题跳到对话中该标题的位置;面板与对话双向定位
|
|
16
|
+
- **可停靠、可缩放** —— 拖顶部横条移动,◀ / ▶ 切换左右停靠,拖边缘调宽高,收起后成为边缘把手;位置与尺寸会被记住
|
|
17
|
+
- **分页** —— 默认显示最近的若干组,向上滚动加载更早的回合
|
|
18
|
+
- **Markdown 感知** —— 标题中的行内标记会被剥离;围栏代码块里的 `#` 行不算标题
|
|
19
|
+
- **主题适配** —— 深色 / 浅色主题自适应;仅对话视图显示,切到其他视图渐隐
|
|
20
|
+
- 对话中没有标题也没有可识别时间时面板自动隐藏
|
|
20
21
|
|
|
21
22
|
## 兼容性
|
|
22
23
|
|
|
23
24
|
| 插件版本 | 支持的 DSH 版本 |
|
|
24
25
|
| --- | --- |
|
|
25
|
-
| **0.
|
|
26
|
-
| 0.
|
|
26
|
+
| **0.4.1**(最新) | 0.1.5-rc.1、0.1.5-rc.2 |
|
|
27
|
+
| 0.4.0 | 0.1.5-rc.1、0.1.5-rc.2 |
|
|
28
|
+
| 0.3.3 | 0.1.5-rc.1 |
|
|
29
|
+
| 0.3.0 – 0.3.2 | ≥ 0.1.2-rc.1 |
|
|
27
30
|
|
|
28
|
-
|
|
31
|
+
`engines.dsh` 下限为 **0.1.5-rc.1**(`>=`,该版本起插件改用会话级槽位注入的宿主接口);0.4.0 起在 **0.1.5-rc.1 与 0.1.5-rc.2** 上验证过并声明兼容,更早或更新的 DSH 版本未经验证,不作声明。在更早的 DSH 上,可安装的最新插件版本是 **0.3.2**。安装或更新时,DSH 市场会依据 `package.json` 中的 `engines.dsh`、`dsh.compatibility.dshReleases` 与 `peerDependencies` 做宿主兼容预检。
|
|
29
32
|
|
|
30
33
|
## 安装
|
|
31
34
|
|
|
@@ -51,11 +54,12 @@ dsh plugin --profile web add <插件目录路径>
|
|
|
51
54
|
|
|
52
55
|
## 使用
|
|
53
56
|
|
|
54
|
-
-
|
|
55
|
-
-
|
|
56
|
-
-
|
|
57
|
-
-
|
|
58
|
-
-
|
|
57
|
+
- **跳转**:点击大纲标题或组头跳到对应位置
|
|
58
|
+
- **搜索**:放大镜打开搜索框,输入后点结果定位并高亮,回车逐处跳,Esc 关闭
|
|
59
|
+
- **层级筛选**:点标题栏的圆形层级按钮弹出 H1–H6 开关,选择显示的层级
|
|
60
|
+
- **移动与停靠**:拖顶部横条移动,◀ / ▶ 切换左右停靠
|
|
61
|
+
- **调整大小**:拖右边缘、下边缘或右下角
|
|
62
|
+
- **加载更早**:在大纲中向上滚动
|
|
59
63
|
|
|
60
64
|
## 开发
|
|
61
65
|
|
package/lib/client.js
CHANGED
|
@@ -78,12 +78,28 @@ window.__ModuleLoader__.load({
|
|
|
78
78
|
return first;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
// Headings are collected line by line so FENCED CODE BLOCKS can be skipped:
|
|
82
|
+
// a "```" block that documents markdown (or shows a shell comment like
|
|
83
|
+
// "# install") contains lines that look like headings but are code — they
|
|
84
|
+
// must not become outline entries (they have no element to jump to, and they
|
|
85
|
+
// shifted the index of the real headings).
|
|
81
86
|
function parseHeadings(text) {
|
|
82
87
|
var items = [];
|
|
83
|
-
|
|
84
|
-
var
|
|
85
|
-
|
|
86
|
-
|
|
88
|
+
if (!text) return items;
|
|
89
|
+
var lines = text.split("\n");
|
|
90
|
+
var fence = null; // { char, len } while inside a fenced code block
|
|
91
|
+
for (var i = 0; i < lines.length; i++) {
|
|
92
|
+
var line = lines[i];
|
|
93
|
+
var fm = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
|
|
94
|
+
if (fence !== null) {
|
|
95
|
+
// a closing fence uses the same character, is at least as long and has
|
|
96
|
+
// no info string after it
|
|
97
|
+
if (fm && fm[1].charAt(0) === fence.char && fm[1].length >= fence.len && fm[2].trim() === "") fence = null;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (fm) { fence = { char: fm[1].charAt(0), len: fm[1].length }; continue; }
|
|
101
|
+
var m = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
|
|
102
|
+
if (m !== null) items.push({ level: m[1].length, title: cleanTitle(m[2].trim()) });
|
|
87
103
|
}
|
|
88
104
|
return items;
|
|
89
105
|
}
|
|
@@ -101,6 +117,74 @@ window.__ModuleLoader__.load({
|
|
|
101
117
|
return root.children;
|
|
102
118
|
}
|
|
103
119
|
|
|
120
|
+
// ---- per-node extraction cache ----
|
|
121
|
+
// The conversation store keeps the same node objects for unchanged messages,
|
|
122
|
+
// so a streaming update only re-joins/re-parses the node that actually
|
|
123
|
+
// changed instead of every message in the history.
|
|
124
|
+
var EMPTY_HEADINGS = [];
|
|
125
|
+
var nodeInfoCache = new Map();
|
|
126
|
+
function nodeInfo(key, node) {
|
|
127
|
+
var isUser = node.kind === "user";
|
|
128
|
+
var blocks = node.data ? node.data.blocks : undefined;
|
|
129
|
+
var cached = nodeInfoCache.get(key);
|
|
130
|
+
if (cached !== undefined &&
|
|
131
|
+
(cached.node === node || (blocks !== undefined && cached.blocks === blocks && cached.user === isUser))) {
|
|
132
|
+
return cached;
|
|
133
|
+
}
|
|
134
|
+
var text = isUser ? extractUserText(node) : extractReplyText(node);
|
|
135
|
+
var info = {
|
|
136
|
+
node: node,
|
|
137
|
+
blocks: blocks,
|
|
138
|
+
user: isUser,
|
|
139
|
+
text: text,
|
|
140
|
+
headings: node.kind === "assistant-step" ? parseHeadings(text) : EMPTY_HEADINGS
|
|
141
|
+
};
|
|
142
|
+
nodeInfoCache.set(key, info);
|
|
143
|
+
return info;
|
|
144
|
+
}
|
|
145
|
+
// Drop cache records whose node left the conversation (bounded growth).
|
|
146
|
+
function pruneNodeInfo(seen) {
|
|
147
|
+
if (nodeInfoCache.size <= 200) return;
|
|
148
|
+
nodeInfoCache.forEach(function (value, key) {
|
|
149
|
+
if (!seen[key]) nodeInfoCache.delete(key);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// stable id for one heading occurrence (a message can hold several)
|
|
154
|
+
function headingId(h) {
|
|
155
|
+
return h.key + "#" + h.idx;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Split `text` into runs around case-insensitive occurrences of `q`, so the
|
|
159
|
+
// caller can render the hits distinctly. Returns [{ text, hit }, ...].
|
|
160
|
+
function highlightParts(text, q) {
|
|
161
|
+
var parts = [];
|
|
162
|
+
if (!text) return parts;
|
|
163
|
+
if (!q) return [{ text: text, hit: false }];
|
|
164
|
+
var lower = text.toLowerCase();
|
|
165
|
+
var from = 0;
|
|
166
|
+
for (;;) {
|
|
167
|
+
var at = lower.indexOf(q, from);
|
|
168
|
+
if (at === -1) break;
|
|
169
|
+
if (at > from) parts.push({ text: text.slice(from, at), hit: false });
|
|
170
|
+
parts.push({ text: text.slice(at, at + q.length), hit: true });
|
|
171
|
+
from = at + q.length;
|
|
172
|
+
}
|
|
173
|
+
if (from < text.length) parts.push({ text: text.slice(from), hit: false });
|
|
174
|
+
return parts;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// A one-line window around the first hit of `q` in `text` (for search results).
|
|
178
|
+
function snippetAround(text, q, span) {
|
|
179
|
+
if (!text) return "";
|
|
180
|
+
var flat = text.replace(/\s+/g, " ").trim();
|
|
181
|
+
var at = flat.toLowerCase().indexOf(q);
|
|
182
|
+
if (at === -1) return previewText(flat, span * 2);
|
|
183
|
+
var start = Math.max(0, at - span);
|
|
184
|
+
var end = Math.min(flat.length, at + q.length + span);
|
|
185
|
+
return (start > 0 ? "…" : "") + flat.slice(start, end) + (end < flat.length ? "…" : "");
|
|
186
|
+
}
|
|
187
|
+
|
|
104
188
|
// strict lookup: exact dataset match or CSS-escaped selector only —
|
|
105
189
|
// no fuzzy contains matching (avoids landing on the wrong row);
|
|
106
190
|
// hidden rows (zero rect, e.g. duplicate/hidden copies) are skipped
|
|
@@ -145,6 +229,10 @@ window.__ModuleLoader__.load({
|
|
|
145
229
|
|
|
146
230
|
// count every occurrence of q (case-insensitive) inside text
|
|
147
231
|
function countOccurrences(text, q) {
|
|
232
|
+
// an empty needle would make indexOf return 0 forever and freeze the tab;
|
|
233
|
+
// every caller passes a trimmed non-empty query today, but the guard keeps
|
|
234
|
+
// a future call site from hanging the UI.
|
|
235
|
+
if (!text || !q) return 0;
|
|
148
236
|
var lower = text.toLowerCase();
|
|
149
237
|
var count = 0;
|
|
150
238
|
var idx = 0;
|
|
@@ -245,26 +333,36 @@ window.__ModuleLoader__.load({
|
|
|
245
333
|
var text = node.nodeValue;
|
|
246
334
|
if (!text) continue;
|
|
247
335
|
var lower = text.toLowerCase();
|
|
248
|
-
|
|
249
|
-
if (idx < 0) continue;
|
|
336
|
+
if (lower.indexOf(q) < 0) continue;
|
|
250
337
|
var frag = document.createDocumentFragment();
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
338
|
+
// EVERY occurrence inside this text node must be wrapped, in order: the
|
|
339
|
+
// caller addresses hits by their global index (`occ`), which is counted
|
|
340
|
+
// over the message text. Wrapping only the first one per text node made
|
|
341
|
+
// a second hit on the same line un-markable, so stepping to it found no
|
|
342
|
+
// `.dqt-current` and fell back to a plain scroll with no highlight.
|
|
343
|
+
var from = 0;
|
|
344
|
+
var idx = lower.indexOf(q, from);
|
|
345
|
+
while (idx !== -1) {
|
|
346
|
+
var before = text.slice(from, idx);
|
|
347
|
+
if (before) frag.appendChild(document.createTextNode(before));
|
|
348
|
+
var mark = document.createElement("span");
|
|
349
|
+
if (occ === currentOcc) {
|
|
350
|
+
mark.className = "dqt-current";
|
|
351
|
+
mark.style.background = "rgba(79,140,255,0.55)";
|
|
352
|
+
mark.style.boxShadow = "0 0 0 1px rgba(79,140,255,0.85)";
|
|
353
|
+
} else {
|
|
354
|
+
mark.style.background = "rgba(79,140,255,0.32)";
|
|
355
|
+
}
|
|
356
|
+
mark.style.borderRadius = "2px";
|
|
357
|
+
mark.style.color = "inherit";
|
|
358
|
+
mark.textContent = text.slice(idx, idx + q.length);
|
|
359
|
+
frag.appendChild(mark);
|
|
360
|
+
highlightSpans.push(mark);
|
|
361
|
+
occ++;
|
|
362
|
+
from = idx + q.length;
|
|
363
|
+
idx = lower.indexOf(q, from);
|
|
260
364
|
}
|
|
261
|
-
|
|
262
|
-
mark.style.color = "inherit";
|
|
263
|
-
mark.textContent = text.slice(idx, idx + q.length);
|
|
264
|
-
frag.appendChild(mark);
|
|
265
|
-
highlightSpans.push(mark);
|
|
266
|
-
occ++;
|
|
267
|
-
var after = text.slice(idx + q.length);
|
|
365
|
+
var after = text.slice(from);
|
|
268
366
|
if (after) frag.appendChild(document.createTextNode(after));
|
|
269
367
|
node.parentNode.replaceChild(frag, node);
|
|
270
368
|
}
|
|
@@ -475,6 +573,88 @@ window.__ModuleLoader__.load({
|
|
|
475
573
|
var panelH = _s8[0];
|
|
476
574
|
var setPanelH = _s8[1];
|
|
477
575
|
|
|
576
|
+
// ---- heading level filter (persisted) ----
|
|
577
|
+
// An arbitrary SET of levels, not a "show up to N" prefix: every chip is an
|
|
578
|
+
// independent on/off switch, so H1 + H3 without H2 is a valid view. Turning
|
|
579
|
+
// the last remaining level off restores all six (the panel is never empty).
|
|
580
|
+
var LEVELS_KEY = "dsh-quick-toc.levels.v1";
|
|
581
|
+
var OLD_MAX_LEVEL_KEY = "dsh-quick-toc.maxLevel.v1";
|
|
582
|
+
var ALL_LEVELS = [1, 2, 3, 4, 5, 6];
|
|
583
|
+
var _sLv = react.useState(function () {
|
|
584
|
+
try {
|
|
585
|
+
var raw = localStorage.getItem(LEVELS_KEY);
|
|
586
|
+
if (raw !== null) {
|
|
587
|
+
var arr = JSON.parse(raw);
|
|
588
|
+
if (Array.isArray(arr)) {
|
|
589
|
+
var kept = ALL_LEVELS.filter(function (lv) { return arr.indexOf(lv) !== -1; });
|
|
590
|
+
if (kept.length > 0) return kept;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
// migrate the "show up to N" value written by earlier builds
|
|
594
|
+
var v = Number(localStorage.getItem(OLD_MAX_LEVEL_KEY));
|
|
595
|
+
if (isFinite(v) && v >= 1 && v <= 6) {
|
|
596
|
+
return ALL_LEVELS.filter(function (lv) { return lv <= v; });
|
|
597
|
+
}
|
|
598
|
+
} catch (e) {}
|
|
599
|
+
return ALL_LEVELS.slice();
|
|
600
|
+
});
|
|
601
|
+
var levels = _sLv[0];
|
|
602
|
+
var setLevels = _sLv[1];
|
|
603
|
+
var levelSet = react.useMemo(function () {
|
|
604
|
+
var m = {};
|
|
605
|
+
for (var i = 0; i < levels.length; i++) m[levels[i]] = true;
|
|
606
|
+
return m;
|
|
607
|
+
}, [levels]);
|
|
608
|
+
var toggleLevel = function (lv) {
|
|
609
|
+
var next;
|
|
610
|
+
if (levelSet[lv]) {
|
|
611
|
+
next = levels.filter(function (x) { return x !== lv; });
|
|
612
|
+
if (next.length === 0) next = ALL_LEVELS.slice(); // never leave the panel empty
|
|
613
|
+
} else {
|
|
614
|
+
next = ALL_LEVELS.filter(function (x) { return x === lv || levelSet[x]; });
|
|
615
|
+
}
|
|
616
|
+
setLevels(next);
|
|
617
|
+
try { localStorage.setItem(LEVELS_KEY, JSON.stringify(next)); } catch (e) {}
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
// ---- heading-level picker popup (transient): opens from the round button
|
|
621
|
+
// next to the search button; closes on an outside pointerdown. Closing
|
|
622
|
+
// plays a short shrink-back animation before unmounting. ----
|
|
623
|
+
var _sLo = react.useState(false);
|
|
624
|
+
var levelsOpen = _sLo[0];
|
|
625
|
+
var setLevelsOpen = _sLo[1];
|
|
626
|
+
var _sLc = react.useState(false);
|
|
627
|
+
var levelsClosing = _sLc[0];
|
|
628
|
+
var setLevelsClosing = _sLc[1];
|
|
629
|
+
var closeLevels = function () {
|
|
630
|
+
if (!levelsOpen || levelsClosing) return;
|
|
631
|
+
setLevelsClosing(true);
|
|
632
|
+
// unmount when the exit animation actually finishes (fallback timer in
|
|
633
|
+
// case animationend never fires, e.g. display:none ancestors)
|
|
634
|
+
var popped = document.querySelector(".dqt-levels-pop");
|
|
635
|
+
var done = false;
|
|
636
|
+
var finish = function () {
|
|
637
|
+
if (done) return;
|
|
638
|
+
done = true;
|
|
639
|
+
if (popped && popped.removeEventListener) popped.removeEventListener("animationend", finish);
|
|
640
|
+
setLevelsOpen(false);
|
|
641
|
+
setLevelsClosing(false);
|
|
642
|
+
};
|
|
643
|
+
if (popped && popped.addEventListener) popped.addEventListener("animationend", finish);
|
|
644
|
+
setTimeout(finish, 220);
|
|
645
|
+
};
|
|
646
|
+
react.useEffect(function () {
|
|
647
|
+
if (!levelsOpen) return;
|
|
648
|
+
var close = function (e) {
|
|
649
|
+
var t = e.target;
|
|
650
|
+
if (t && t.closest && (t.closest(".dqt-levels-pop") || t.closest(".dqt-levels-btn"))) return;
|
|
651
|
+
closeLevels();
|
|
652
|
+
};
|
|
653
|
+
document.addEventListener("pointerdown", close);
|
|
654
|
+
return function () { document.removeEventListener("pointerdown", close); };
|
|
655
|
+
}, [levelsOpen, levelsClosing]);
|
|
656
|
+
|
|
657
|
+
|
|
478
658
|
// ---- pagination: show the latest N groups; scrolling to the top loads older ----
|
|
479
659
|
var PAGE_SIZE = 6;
|
|
480
660
|
var _s9 = react.useState(PAGE_SIZE);
|
|
@@ -593,13 +773,6 @@ window.__ModuleLoader__.load({
|
|
|
593
773
|
var setActiveGroup = _s10[1];
|
|
594
774
|
var activeSigRef = react.useRef("");
|
|
595
775
|
|
|
596
|
-
// first time content appears, scroll the list to the bottom (newest)
|
|
597
|
-
react.useEffect(function () {
|
|
598
|
-
if (didInitScroll.current || !listRef.current || !groups || groups.length === 0) return;
|
|
599
|
-
didInitScroll.current = true;
|
|
600
|
-
listRef.current.scrollTop = listRef.current.scrollHeight;
|
|
601
|
-
}, [groups ? groups.length : 0]);
|
|
602
|
-
|
|
603
776
|
// load older outline groups; when everything is loaded, click the
|
|
604
777
|
// conversation's own "load older" button so older turns keep appearing
|
|
605
778
|
var loadOlderOutline = function (el) {
|
|
@@ -760,6 +933,7 @@ window.__ModuleLoader__.load({
|
|
|
760
933
|
var turnUserText = {};
|
|
761
934
|
var turnUserFull = {};
|
|
762
935
|
if (!order || !nodes) return result;
|
|
936
|
+
var seen = {};
|
|
763
937
|
// first pass: per-turn time — the LAST message of the turn wins (end time);
|
|
764
938
|
// also remember each turn's user message key + first-line preview + full text
|
|
765
939
|
for (var i = 0; i < order.length; i++) {
|
|
@@ -769,10 +943,12 @@ window.__ModuleLoader__.load({
|
|
|
769
943
|
var l0 = n0.location;
|
|
770
944
|
var tid0 = l0 && (l0.kind === "turn" || l0.kind === "step") && l0.turn ? l0.turn.turn : null;
|
|
771
945
|
if (tid0 === null) continue;
|
|
946
|
+
seen[k0] = true;
|
|
947
|
+
var info0 = nodeInfo(k0, n0);
|
|
772
948
|
if (n0.kind === "user" && turnUserKey[tid0] === undefined) {
|
|
773
949
|
turnUserKey[tid0] = k0;
|
|
774
|
-
turnUserText[tid0] = previewText(
|
|
775
|
-
turnUserFull[tid0] =
|
|
950
|
+
turnUserText[tid0] = previewText(info0.text, 30);
|
|
951
|
+
turnUserFull[tid0] = info0.text;
|
|
776
952
|
}
|
|
777
953
|
var t0 = getNodeTime(n0);
|
|
778
954
|
if (t0) turnTimes[tid0] = t0;
|
|
@@ -797,22 +973,37 @@ window.__ModuleLoader__.load({
|
|
|
797
973
|
};
|
|
798
974
|
result.push(current);
|
|
799
975
|
}
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
var
|
|
804
|
-
|
|
805
|
-
current.headings.push({ level: parsed[k].level, title: parsed[k].title, key: key, idx: hIdx });
|
|
806
|
-
hIdx++;
|
|
976
|
+
seen[key] = true;
|
|
977
|
+
var info = nodeInfo(key, node);
|
|
978
|
+
current.msgs.push({ key: key, text: info.text });
|
|
979
|
+
for (var k = 0; k < info.headings.length; k++) {
|
|
980
|
+
current.headings.push({ level: info.headings[k].level, title: info.headings[k].title, key: key, idx: k });
|
|
807
981
|
}
|
|
808
982
|
}
|
|
983
|
+
pruneNodeInfo(seen);
|
|
809
984
|
// keep a turn when it has headings OR a time — turns without headings
|
|
810
985
|
// still get a standalone time entry in the outline (click to jump)
|
|
811
986
|
return result.filter(function (g) { return g.headings.length > 0 || g.time !== ""; });
|
|
812
987
|
}, [order, nodes]);
|
|
988
|
+
// first time content appears, scroll the list to the bottom (newest).
|
|
989
|
+
// NOTE: must stay BELOW the `groups` memo — a deps array is evaluated
|
|
990
|
+
// during render, so reading `groups` before that `var` is assigned would
|
|
991
|
+
// freeze the deps at 0 and the effect would only ever run on mount.
|
|
992
|
+
react.useEffect(function () {
|
|
993
|
+
if (didInitScroll.current || !listRef.current || groups.length === 0) return;
|
|
994
|
+
didInitScroll.current = true;
|
|
995
|
+
listRef.current.scrollTop = listRef.current.scrollHeight;
|
|
996
|
+
}, [groups.length]);
|
|
813
997
|
var groupTrees = react.useMemo(function () {
|
|
814
|
-
return groups.map(function (g) {
|
|
815
|
-
|
|
998
|
+
return groups.map(function (g) {
|
|
999
|
+
if (levels.length === 6) return buildTree(g.headings);
|
|
1000
|
+
var kept = [];
|
|
1001
|
+
for (var i = 0; i < g.headings.length; i++) {
|
|
1002
|
+
if (levelSet[g.headings[i].level]) kept.push(g.headings[i]);
|
|
1003
|
+
}
|
|
1004
|
+
return buildTree(kept);
|
|
1005
|
+
});
|
|
1006
|
+
}, [groups, levels, levelSet]);
|
|
816
1007
|
// pagination slice: the latest `visibleCount` groups
|
|
817
1008
|
var shownGroups = groups.slice(Math.max(0, groups.length - visibleCount));
|
|
818
1009
|
var shownTrees = groupTrees.slice(groupTrees.length - shownGroups.length);
|
|
@@ -828,6 +1019,79 @@ window.__ModuleLoader__.load({
|
|
|
828
1019
|
}
|
|
829
1020
|
return m;
|
|
830
1021
|
}, [groups]);
|
|
1022
|
+
|
|
1023
|
+
// ancestor path of every heading occurrence (used by the search result rows):
|
|
1024
|
+
// id -> { path, title, level, gi }
|
|
1025
|
+
var headingPaths = react.useMemo(function () {
|
|
1026
|
+
var byId = {};
|
|
1027
|
+
var lastTitle = {};
|
|
1028
|
+
for (var gi = 0; gi < groups.length; gi++) {
|
|
1029
|
+
var g = groups[gi];
|
|
1030
|
+
for (var j = 0; j < g.headings.length; j++) {
|
|
1031
|
+
var h = g.headings[j];
|
|
1032
|
+
for (var deeper = h.level + 1; deeper <= 6; deeper++) lastTitle[deeper] = undefined;
|
|
1033
|
+
var parts = [];
|
|
1034
|
+
for (var up = 1; up < h.level; up++) {
|
|
1035
|
+
if (lastTitle[up]) parts.push(lastTitle[up]);
|
|
1036
|
+
}
|
|
1037
|
+
parts.push(h.title);
|
|
1038
|
+
byId[headingId(h)] = { path: parts.join(" › "), title: h.title, level: h.level, gi: gi };
|
|
1039
|
+
lastTitle[h.level] = h.title;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
return byId;
|
|
1043
|
+
}, [groups]);
|
|
1044
|
+
|
|
1045
|
+
// search result rows: one row per matched heading/message (with an
|
|
1046
|
+
// occurrence count), carrying its heading path, turn time and a snippet.
|
|
1047
|
+
var resultRows = react.useMemo(function () {
|
|
1048
|
+
var q = query.trim().toLowerCase();
|
|
1049
|
+
if (!q) return [];
|
|
1050
|
+
var rows = [];
|
|
1051
|
+
var index = {};
|
|
1052
|
+
var push = function (row, n) {
|
|
1053
|
+
var id = row.key + "#" + (row.idx === undefined ? "-" : row.idx);
|
|
1054
|
+
var known = index[id];
|
|
1055
|
+
if (known !== undefined) { known.count += n; return; }
|
|
1056
|
+
row.count = n;
|
|
1057
|
+
index[id] = row;
|
|
1058
|
+
rows.push(row);
|
|
1059
|
+
};
|
|
1060
|
+
for (var gi = 0; gi < groups.length; gi++) {
|
|
1061
|
+
var g = groups[gi];
|
|
1062
|
+
for (var j = 0; j < g.headings.length; j++) {
|
|
1063
|
+
var h = g.headings[j];
|
|
1064
|
+
var nh = countOccurrences(h.title, q);
|
|
1065
|
+
if (nh > 0) {
|
|
1066
|
+
var hp = headingPaths[headingId(h)];
|
|
1067
|
+
push({
|
|
1068
|
+
gi: gi, key: h.key, idx: h.idx, title: h.title, level: h.level,
|
|
1069
|
+
path: hp ? hp.path : "", time: g.time, snippet: ""
|
|
1070
|
+
}, nh);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
if (searchScope === "full") {
|
|
1074
|
+
var nu = g.userKey && g.userFull ? countOccurrences(g.userFull, q) : 0;
|
|
1075
|
+
if (nu > 0) {
|
|
1076
|
+
push({
|
|
1077
|
+
gi: gi, key: g.userKey, idx: undefined, title: previewText(g.userFull, 40),
|
|
1078
|
+
level: 0, path: "", time: g.time, snippet: snippetAround(g.userFull, q, 36)
|
|
1079
|
+
}, nu);
|
|
1080
|
+
}
|
|
1081
|
+
for (var m = 0; m < g.msgs.length; m++) {
|
|
1082
|
+
var msg = g.msgs[m];
|
|
1083
|
+
if (!msg.text) continue;
|
|
1084
|
+
var nm = countOccurrences(msg.text, q);
|
|
1085
|
+
if (nm === 0) continue;
|
|
1086
|
+
push({
|
|
1087
|
+
gi: gi, key: msg.key, idx: undefined, title: previewText(msg.text, 40),
|
|
1088
|
+
level: 0, path: "", time: g.time, snippet: snippetAround(msg.text, q, 36)
|
|
1089
|
+
}, nm);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
return rows;
|
|
1094
|
+
}, [groups, query, searchScope, headingPaths]);
|
|
831
1095
|
react.useEffect(function () {
|
|
832
1096
|
var sp = document.querySelector("[data-conversation-scroll]");
|
|
833
1097
|
if (!sp) return;
|
|
@@ -918,6 +1182,46 @@ window.__ModuleLoader__.load({
|
|
|
918
1182
|
return out;
|
|
919
1183
|
}, [groups, query, searchScope]);
|
|
920
1184
|
|
|
1185
|
+
// which result row holds the current (n/N) occurrence (needs `matches`)
|
|
1186
|
+
var activeResultRow = -1;
|
|
1187
|
+
if (resultRows.length > 0 && matches.length > 0) {
|
|
1188
|
+
var acc = 0;
|
|
1189
|
+
var target = matchIdx % matches.length;
|
|
1190
|
+
for (var ri = 0; ri < resultRows.length; ri++) {
|
|
1191
|
+
acc += resultRows[ri].count;
|
|
1192
|
+
if (target < acc) { activeResultRow = ri; break; }
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// The result list follows the outline's convention: OLDEST hit at the top,
|
|
1197
|
+
// NEWEST at the bottom, and a fresh search starts scrolled to the bottom —
|
|
1198
|
+
// scroll UP from there to walk back through earlier hits. The newest hit is
|
|
1199
|
+
// also made the current one, so the highlighted row is the one on screen
|
|
1200
|
+
// (otherwise n/N points at a row that is off-view).
|
|
1201
|
+
var searchPosRef = react.useRef("");
|
|
1202
|
+
react.useEffect(function () {
|
|
1203
|
+
var q = query.trim();
|
|
1204
|
+
if (searchPosRef.current === q) return;
|
|
1205
|
+
searchPosRef.current = q;
|
|
1206
|
+
if (!q) return;
|
|
1207
|
+
var el = listRef.current;
|
|
1208
|
+
if (el) el.scrollTop = el.scrollHeight;
|
|
1209
|
+
if (matches.length > 0) setMatchIdx(matches.length - 1);
|
|
1210
|
+
}, [query, matches.length]);
|
|
1211
|
+
|
|
1212
|
+
// keep the current row in view while stepping with Enter
|
|
1213
|
+
react.useEffect(function () {
|
|
1214
|
+
if (!query.trim() || activeResultRow < 0) return;
|
|
1215
|
+
var el = listRef.current;
|
|
1216
|
+
if (!el || !el.querySelector) return;
|
|
1217
|
+
var node = el.querySelector('[data-result-idx="' + activeResultRow + '"]');
|
|
1218
|
+
if (!node) return;
|
|
1219
|
+
var er = node.getBoundingClientRect();
|
|
1220
|
+
var lr2 = el.getBoundingClientRect();
|
|
1221
|
+
if (er.top < lr2.top + 2) el.scrollTop += er.top - lr2.top - 2;
|
|
1222
|
+
else if (er.bottom > lr2.bottom - 2) el.scrollTop += er.bottom - lr2.bottom + 2;
|
|
1223
|
+
}, [activeResultRow, query]);
|
|
1224
|
+
|
|
921
1225
|
// ---- nothing to show without headings ----
|
|
922
1226
|
if (groups.length === 0) return null;
|
|
923
1227
|
|
|
@@ -949,15 +1253,19 @@ window.__ModuleLoader__.load({
|
|
|
949
1253
|
if (e.button !== 0) return;
|
|
950
1254
|
var startY = e.clientY;
|
|
951
1255
|
var origY = panelY;
|
|
1256
|
+
var lastY = panelY;
|
|
952
1257
|
var move = function (ev) {
|
|
953
1258
|
var next = origY + (ev.clientY - startY);
|
|
954
1259
|
next = Math.max(-(viewport ? viewport.top : 0), Math.min(window.innerHeight - 90, next));
|
|
1260
|
+
lastY = next;
|
|
955
1261
|
setPanelY(next);
|
|
956
1262
|
};
|
|
957
1263
|
var up = function () {
|
|
958
1264
|
window.removeEventListener("pointermove", move);
|
|
959
1265
|
window.removeEventListener("pointerup", up);
|
|
960
|
-
|
|
1266
|
+
// persist the DRAGGED value: `panelY` here is the value captured when
|
|
1267
|
+
// the drag started (the state setter has not re-rendered this closure)
|
|
1268
|
+
try { localStorage.setItem(PANEL_Y_KEY, String(lastY)); } catch (e2) {}
|
|
961
1269
|
};
|
|
962
1270
|
window.addEventListener("pointermove", move);
|
|
963
1271
|
window.addEventListener("pointerup", up);
|
|
@@ -1033,6 +1341,15 @@ window.__ModuleLoader__.load({
|
|
|
1033
1341
|
}, 150);
|
|
1034
1342
|
};
|
|
1035
1343
|
|
|
1344
|
+
// clicking result row `i` = make its FIRST occurrence the current match, so
|
|
1345
|
+
// it highlights + scrolls exactly like Enter stepping does (rows are deduped
|
|
1346
|
+
// one-per-message, so row i starts at the sum of the previous rows' counts)
|
|
1347
|
+
var goToRow = function (i) {
|
|
1348
|
+
var start = 0;
|
|
1349
|
+
for (var k = 0; k < i && k < resultRows.length; k++) start += resultRows[k].count;
|
|
1350
|
+
goToMatch(start);
|
|
1351
|
+
};
|
|
1352
|
+
|
|
1036
1353
|
// ---- circular icon button (glyph optical offset: ox/oy px) ----
|
|
1037
1354
|
var iconBtn = function (onClick, tip, glyph, fontSize, offset) {
|
|
1038
1355
|
var ox = offset ? (offset.x || 0) : 0;
|
|
@@ -1201,6 +1518,55 @@ window.__ModuleLoader__.load({
|
|
|
1201
1518
|
react_jsx_runtime.jsx("div", {
|
|
1202
1519
|
style: { display: "flex", alignItems: "center", gap: 6, flex: "none" },
|
|
1203
1520
|
children: [
|
|
1521
|
+
// heading-level filter: a round icon button (directly left
|
|
1522
|
+
// of the search button) that pops down the H1–H6 picker.
|
|
1523
|
+
// Icon: three lines of decreasing width = outline levels.
|
|
1524
|
+
// Hover behaviour matches the magnifier: background + icon
|
|
1525
|
+
// tint change; the resting look never shifts when open.
|
|
1526
|
+
react_jsx_runtime.jsx("button", {
|
|
1527
|
+
className: "dqt-levels-btn",
|
|
1528
|
+
onClick: function () { levelsOpen ? closeLevels() : setLevelsOpen(true); },
|
|
1529
|
+
title: "标题层级筛选",
|
|
1530
|
+
style: {
|
|
1531
|
+
width: 24,
|
|
1532
|
+
height: 24,
|
|
1533
|
+
padding: 0,
|
|
1534
|
+
border: "none",
|
|
1535
|
+
borderRadius: "50%",
|
|
1536
|
+
cornerShape: "round",
|
|
1537
|
+
cursor: "pointer",
|
|
1538
|
+
flex: "none",
|
|
1539
|
+
display: "flex",
|
|
1540
|
+
alignItems: "center",
|
|
1541
|
+
justifyContent: "center",
|
|
1542
|
+
background: "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))",
|
|
1543
|
+
color: C.muted,
|
|
1544
|
+
transition: "background 0.15s ease, color 0.15s ease"
|
|
1545
|
+
},
|
|
1546
|
+
onMouseEnter: function (e) {
|
|
1547
|
+
e.currentTarget.style.background = "var(--dsw-alias-interactive-bg-active, rgba(79,140,255,0.24))";
|
|
1548
|
+
e.currentTarget.style.color = "var(--dsw-alias-brand-primary, #4f8cff)";
|
|
1549
|
+
},
|
|
1550
|
+
onMouseLeave: function (e) {
|
|
1551
|
+
e.currentTarget.style.background = "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))";
|
|
1552
|
+
e.currentTarget.style.color = C.muted;
|
|
1553
|
+
},
|
|
1554
|
+
children: react_jsx_runtime.jsx("svg", {
|
|
1555
|
+
width: 14,
|
|
1556
|
+
height: 14,
|
|
1557
|
+
viewBox: "0 0 14 14",
|
|
1558
|
+
fill: "none",
|
|
1559
|
+
stroke: "currentColor",
|
|
1560
|
+
strokeWidth: 1.8,
|
|
1561
|
+
strokeLinecap: "round",
|
|
1562
|
+
style: { display: "block" },
|
|
1563
|
+
children: [
|
|
1564
|
+
react_jsx_runtime.jsx("line", { x1: 1.5, y1: 3, x2: 12.5, y2: 3 }),
|
|
1565
|
+
react_jsx_runtime.jsx("line", { x1: 1.5, y1: 7, x2: 9, y2: 7 }),
|
|
1566
|
+
react_jsx_runtime.jsx("line", { x1: 1.5, y1: 11, x2: 5.5, y2: 11 })
|
|
1567
|
+
]
|
|
1568
|
+
})
|
|
1569
|
+
}),
|
|
1204
1570
|
// magnifier button (SVG, matches the other buttons' style)
|
|
1205
1571
|
react_jsx_runtime.jsx("button", {
|
|
1206
1572
|
onClick: function () { searchOpen ? closeSearch() : openSearch(); },
|
|
@@ -1450,7 +1816,11 @@ window.__ModuleLoader__.load({
|
|
|
1450
1816
|
className: "dqt-list",
|
|
1451
1817
|
style: {
|
|
1452
1818
|
overflowY: "auto",
|
|
1453
|
-
padding:
|
|
1819
|
+
// NO top padding: a sticky child is confined to its containing
|
|
1820
|
+
// block, so a padded scroll container would hold the pinned group
|
|
1821
|
+
// header that many pixels below the toolbar (a visible gap). Spacing
|
|
1822
|
+
// between groups comes from the per-group divider instead.
|
|
1823
|
+
padding: "0 8px 6px",
|
|
1454
1824
|
flex: "1 1 auto",
|
|
1455
1825
|
minHeight: 0,
|
|
1456
1826
|
direction: dockRight ? "ltr" : "rtl"
|
|
@@ -1459,7 +1829,9 @@ window.__ModuleLoader__.load({
|
|
|
1459
1829
|
onWheel: onListWheel,
|
|
1460
1830
|
children: react_jsx_runtime.jsx("div", {
|
|
1461
1831
|
style: { direction: "ltr" },
|
|
1462
|
-
children:
|
|
1832
|
+
children: query.trim()
|
|
1833
|
+
? renderResults(resultRows, query.trim().toLowerCase(), C, goToRow, activeResultRow)
|
|
1834
|
+
: renderGroups(shownGroups, shownTrees, jump, C, Math.max(0, groups.length - visibleCount), activeGroup)
|
|
1463
1835
|
})
|
|
1464
1836
|
}),
|
|
1465
1837
|
// resize handles (right edge: width, bottom edge: height)
|
|
@@ -1505,7 +1877,58 @@ window.__ModuleLoader__.load({
|
|
|
1505
1877
|
},
|
|
1506
1878
|
onPointerDown: onResizeCornerDown,
|
|
1507
1879
|
title: "拖拽同时调整宽高"
|
|
1508
|
-
})
|
|
1880
|
+
}),
|
|
1881
|
+
// heading-level picker: pops down from the round header button,
|
|
1882
|
+
// right-aligned, with an outer shadow so it reads as a floating layer.
|
|
1883
|
+
// Open/close animate from the button's center: the animation lives in
|
|
1884
|
+
// the injected stylesheet (classes, not inline animation) so React
|
|
1885
|
+
// re-renders never restart or cancel it — an inline `animation` set on
|
|
1886
|
+
// every render made the entrance invisible and the exit never play.
|
|
1887
|
+
(levelsOpen || levelsClosing) ? react_jsx_runtime.jsx("div", {
|
|
1888
|
+
className: "dqt-levels-pop" + (levelsClosing ? " dqt-levels-pop-closing" : ""),
|
|
1889
|
+
style: {
|
|
1890
|
+
position: "absolute",
|
|
1891
|
+
top: 40,
|
|
1892
|
+
right: 8,
|
|
1893
|
+
zIndex: 30,
|
|
1894
|
+
display: "flex",
|
|
1895
|
+
alignItems: "center",
|
|
1896
|
+
flexWrap: "wrap",
|
|
1897
|
+
gap: 4,
|
|
1898
|
+
padding: 8,
|
|
1899
|
+
background: C.panelBg,
|
|
1900
|
+
border: "1px solid " + C.panelBorder,
|
|
1901
|
+
borderRadius: 10,
|
|
1902
|
+
cornerShape: "round",
|
|
1903
|
+
boxShadow: "0 6px 20px rgba(0, 0, 0, 0.28), 0 2px 6px rgba(0, 0, 0, 0.18)",
|
|
1904
|
+
maxWidth: "calc(100% - 16px)",
|
|
1905
|
+
transformOrigin: "calc(50% + 12px) top"
|
|
1906
|
+
},
|
|
1907
|
+
children: [1, 2, 3, 4, 5, 6].map(function (lv) {
|
|
1908
|
+
var active = !!levelSet[lv];
|
|
1909
|
+
return react_jsx_runtime.jsx("button", {
|
|
1910
|
+
onClick: function () { toggleLevel(lv); },
|
|
1911
|
+
title: active ? "隐藏 H" + lv : "显示 H" + lv,
|
|
1912
|
+
style: {
|
|
1913
|
+
height: 22,
|
|
1914
|
+
padding: "0 7px",
|
|
1915
|
+
border: "none",
|
|
1916
|
+
borderRadius: 6,
|
|
1917
|
+
cornerShape: "round",
|
|
1918
|
+
cursor: "pointer",
|
|
1919
|
+
fontSize: 11,
|
|
1920
|
+
lineHeight: "22px",
|
|
1921
|
+
fontFamily: "inherit",
|
|
1922
|
+
background: active
|
|
1923
|
+
? "rgba(79,140,255,0.18)"
|
|
1924
|
+
: "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.14))",
|
|
1925
|
+
color: active ? "var(--dsw-alias-brand-primary, #4f8cff)" : C.muted,
|
|
1926
|
+
fontWeight: active ? 600 : 400
|
|
1927
|
+
},
|
|
1928
|
+
children: "H" + lv
|
|
1929
|
+
}, "lv" + lv);
|
|
1930
|
+
})
|
|
1931
|
+
}) : null
|
|
1509
1932
|
]
|
|
1510
1933
|
});
|
|
1511
1934
|
|
|
@@ -1571,6 +1994,12 @@ window.__ModuleLoader__.load({
|
|
|
1571
1994
|
}
|
|
1572
1995
|
|
|
1573
1996
|
function renderItem(n, depth, jump, C, uid) {
|
|
1997
|
+
var hasChildren = !!(n.children && n.children.length);
|
|
1998
|
+
var parts = [];
|
|
1999
|
+
parts.push(react_jsx_runtime.jsx("span", {
|
|
2000
|
+
style: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis" },
|
|
2001
|
+
children: n.title
|
|
2002
|
+
}, uid + "-l"));
|
|
1574
2003
|
return react_jsx_runtime.jsx(
|
|
1575
2004
|
"div",
|
|
1576
2005
|
{
|
|
@@ -1578,8 +2007,11 @@ window.__ModuleLoader__.load({
|
|
|
1578
2007
|
"data-jump-key": n.key,
|
|
1579
2008
|
"data-jump-idx": n.idx !== undefined ? String(n.idx) : "0",
|
|
1580
2009
|
style: {
|
|
2010
|
+
display: "flex",
|
|
2011
|
+
alignItems: "center",
|
|
2012
|
+
gap: 2,
|
|
1581
2013
|
padding: "2px 6px",
|
|
1582
|
-
paddingLeft:
|
|
2014
|
+
paddingLeft: (hasChildren ? 2 : 6) + (n.level - 1) * 12,
|
|
1583
2015
|
margin: "1px 0",
|
|
1584
2016
|
borderRadius: 6,
|
|
1585
2017
|
cursor: "pointer",
|
|
@@ -1589,13 +2021,12 @@ window.__ModuleLoader__.load({
|
|
|
1589
2021
|
lineHeight: "18px",
|
|
1590
2022
|
height: 22,
|
|
1591
2023
|
whiteSpace: "nowrap",
|
|
1592
|
-
overflow: "hidden"
|
|
1593
|
-
textOverflow: "ellipsis"
|
|
2024
|
+
overflow: "hidden"
|
|
1594
2025
|
},
|
|
1595
2026
|
onMouseEnter: function (e) { e.currentTarget.style.background = C.hover; },
|
|
1596
2027
|
onMouseLeave: function (e) { e.currentTarget.style.background = "transparent"; },
|
|
1597
2028
|
title: n.title,
|
|
1598
|
-
children:
|
|
2029
|
+
children: parts
|
|
1599
2030
|
},
|
|
1600
2031
|
uid + "-" + n.level + "-" + (n.key || "")
|
|
1601
2032
|
);
|
|
@@ -1613,23 +2044,130 @@ window.__ModuleLoader__.load({
|
|
|
1613
2044
|
return out;
|
|
1614
2045
|
}
|
|
1615
2046
|
|
|
1616
|
-
//
|
|
2047
|
+
// ---- search results view ----
|
|
2048
|
+
// While a query is present the list shows every matched heading/message
|
|
2049
|
+
// (grouped per message, with an occurrence count) instead of the outline, so
|
|
2050
|
+
// search is "see them all, then jump" rather than stepping blindly.
|
|
2051
|
+
function hitSpans(text, q) {
|
|
2052
|
+
var parts = highlightParts(text, q);
|
|
2053
|
+
return parts.map(function (p, i) {
|
|
2054
|
+
return p.hit
|
|
2055
|
+
? react_jsx_runtime.jsx("span", {
|
|
2056
|
+
style: { background: "rgba(255,196,0,0.32)", borderRadius: 2, color: "inherit" },
|
|
2057
|
+
children: p.text
|
|
2058
|
+
}, "hp" + i)
|
|
2059
|
+
: p.text;
|
|
2060
|
+
});
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
function renderResultRow(r, i, q, C, onRow, isActive) {
|
|
2064
|
+
var meta = [];
|
|
2065
|
+
if (r.time) meta.push(r.time);
|
|
2066
|
+
if (r.path) meta.push(r.path);
|
|
2067
|
+
if (r.count > 1) meta.push("×" + r.count);
|
|
2068
|
+
var children = [
|
|
2069
|
+
react_jsx_runtime.jsx("div", {
|
|
2070
|
+
style: {
|
|
2071
|
+
fontSize: 12,
|
|
2072
|
+
fontWeight: r.level > 0 && r.level <= 2 ? 600 : 400,
|
|
2073
|
+
color: r.level > 0 ? C.text : C.muted,
|
|
2074
|
+
whiteSpace: "nowrap",
|
|
2075
|
+
overflow: "hidden",
|
|
2076
|
+
textOverflow: "ellipsis"
|
|
2077
|
+
},
|
|
2078
|
+
children: hitSpans(r.title, q)
|
|
2079
|
+
}, "t" + i)
|
|
2080
|
+
];
|
|
2081
|
+
if (r.snippet) {
|
|
2082
|
+
children.push(react_jsx_runtime.jsx("div", {
|
|
2083
|
+
style: {
|
|
2084
|
+
fontSize: 11,
|
|
2085
|
+
color: C.muted,
|
|
2086
|
+
marginTop: 1,
|
|
2087
|
+
whiteSpace: "nowrap",
|
|
2088
|
+
overflow: "hidden",
|
|
2089
|
+
textOverflow: "ellipsis"
|
|
2090
|
+
},
|
|
2091
|
+
children: hitSpans(r.snippet, q)
|
|
2092
|
+
}, "s" + i));
|
|
2093
|
+
}
|
|
2094
|
+
if (meta.length) {
|
|
2095
|
+
children.push(react_jsx_runtime.jsx("div", {
|
|
2096
|
+
style: { fontSize: 10, color: C.muted, opacity: 0.8, marginTop: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" },
|
|
2097
|
+
children: meta.join(" · ")
|
|
2098
|
+
}, "m" + i));
|
|
2099
|
+
}
|
|
2100
|
+
return react_jsx_runtime.jsx("div", {
|
|
2101
|
+
// clicking a row makes that hit the CURRENT one: it highlights the
|
|
2102
|
+
// keyword in the conversation and scrolls there, exactly like Enter
|
|
2103
|
+
// stepping — a row that only scrolled (no highlight) was the old bug
|
|
2104
|
+
onClick: function () { onRow(i); },
|
|
2105
|
+
"data-result-idx": i,
|
|
2106
|
+
onMouseEnter: function (e) { e.currentTarget.style.background = C.hover; },
|
|
2107
|
+
onMouseLeave: function (e) { e.currentTarget.style.background = isActive ? "rgba(79,140,255,0.16)" : "transparent"; },
|
|
2108
|
+
title: r.path || r.title,
|
|
2109
|
+
style: {
|
|
2110
|
+
padding: "4px 6px",
|
|
2111
|
+
margin: "1px 0",
|
|
2112
|
+
borderRadius: 6,
|
|
2113
|
+
cursor: "pointer",
|
|
2114
|
+
minWidth: 0,
|
|
2115
|
+
background: isActive ? "rgba(79,140,255,0.16)" : "transparent",
|
|
2116
|
+
transition: "background 0.15s ease"
|
|
2117
|
+
},
|
|
2118
|
+
children: children
|
|
2119
|
+
}, "res-" + i);
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
function renderResults(rows, q, C, onRow, activeRow) {
|
|
2123
|
+
if (!rows || rows.length === 0) {
|
|
2124
|
+
return react_jsx_runtime.jsx("div", {
|
|
2125
|
+
style: { padding: "12px 8px", fontSize: 12, color: C.muted, textAlign: "center" },
|
|
2126
|
+
children: "没有匹配"
|
|
2127
|
+
});
|
|
2128
|
+
}
|
|
2129
|
+
var out = [];
|
|
2130
|
+
for (var i = 0; i < rows.length; i++) {
|
|
2131
|
+
out.push(renderResultRow(rows[i], i, q, C, onRow, i === activeRow));
|
|
2132
|
+
}
|
|
2133
|
+
return out;
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
// a group's header (the time row): clicking it jumps to the TOP OF THE
|
|
2137
|
+
// MODEL'S REPLY for that turn — the first assistant step — which also makes
|
|
2138
|
+
// heading-less turns jumpable, since they only have this row.
|
|
1617
2139
|
// Implemented as a function (not an inline closure in a loop) so each
|
|
1618
2140
|
// header captures its own (g, gi) — the var-in-loop closure bug would
|
|
1619
2141
|
// otherwise make every header jump to the last group.
|
|
1620
2142
|
function renderGroupHeader(g, gi, jump, C) {
|
|
2143
|
+
var replyKey = (g.msgs && g.msgs.length > 0) ? g.msgs[0].key : "";
|
|
2144
|
+
var targetKey = replyKey || g.userKey;
|
|
1621
2145
|
var jumpToTurn = function (e) {
|
|
1622
2146
|
e.stopPropagation();
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
if (
|
|
1626
|
-
|
|
2147
|
+
// prefer the model's reply; fall back to the user message, then to the
|
|
2148
|
+
// turn's first heading, so the row always does something useful
|
|
2149
|
+
if (replyKey && findRow(replyKey)) { jump(replyKey); return; }
|
|
2150
|
+
if (g.userKey && findRow(g.userKey)) { jump(g.userKey); return; }
|
|
2151
|
+
if (g.headings.length > 0) jump(g.headings[0].key, g.headings[0].idx);
|
|
1627
2152
|
};
|
|
1628
2153
|
return react_jsx_runtime.jsx("div", {
|
|
1629
|
-
style: {
|
|
2154
|
+
style: {
|
|
2155
|
+
padding: "1px 4px 2px",
|
|
2156
|
+
height: 18,
|
|
2157
|
+
display: "flex",
|
|
2158
|
+
alignItems: "center",
|
|
2159
|
+
minWidth: 0,
|
|
2160
|
+
// sticky: while you scroll the outline, the header of the group you are
|
|
2161
|
+
// inside stays pinned at the top (background must be opaque to cover
|
|
2162
|
+
// the rows scrolling underneath).
|
|
2163
|
+
position: "sticky",
|
|
2164
|
+
top: 0,
|
|
2165
|
+
zIndex: 2,
|
|
2166
|
+
background: C.panelBg
|
|
2167
|
+
},
|
|
1630
2168
|
children: react_jsx_runtime.jsx("span", {
|
|
1631
2169
|
onClick: jumpToTurn,
|
|
1632
|
-
title:
|
|
2170
|
+
title: replyKey ? "跳转到该回合的模型回答开头" : "跳转到该回合开头",
|
|
1633
2171
|
onMouseEnter: function (e) { e.currentTarget.style.background = C.hover; },
|
|
1634
2172
|
onMouseLeave: function (e) { e.currentTarget.style.background = "transparent"; },
|
|
1635
2173
|
style: {
|
|
@@ -1725,6 +2263,12 @@ window.__ModuleLoader__.load({
|
|
|
1725
2263
|
var s = document.createElement("style");
|
|
1726
2264
|
s.id = "dsh-quick-toc-css";
|
|
1727
2265
|
s.textContent = "@keyframes dqt-spin{to{transform:rotate(360deg)}}@keyframes dqt-fade-in{from{opacity:0}to{opacity:1}}@keyframes dqt-fade-out{from{opacity:1}to{opacity:0}}" +
|
|
2266
|
+
"@keyframes dqt-pop-in{from{opacity:0;transform:scale(0.7)}to{opacity:1;transform:scale(1)}}@keyframes dqt-pop-out{from{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(0.7)}}" +
|
|
2267
|
+
// the level picker's enter/exit animations live here as classes so React
|
|
2268
|
+
// re-renders (which rewrite inline styles) cannot restart or cancel them
|
|
2269
|
+
".dqt-levels-pop{animation:dqt-pop-in 0.22s cubic-bezier(0.22, 0.9, 0.3, 1) both;will-change:transform,opacity}" +
|
|
2270
|
+
// exit: starts moving immediately (fast attack), so the dismiss feels snappy
|
|
2271
|
+
".dqt-levels-pop-closing{animation:dqt-pop-out 0.14s cubic-bezier(0.2, 0.9, 0.3, 1) both;will-change:transform,opacity}" +
|
|
1728
2272
|
// Hide the native scrollbar entirely (no arrow buttons, no grey bar) —
|
|
1729
2273
|
// custom webkit scrollbar styling was not reliably suppressing the
|
|
1730
2274
|
// default arrows, so a hidden native bar + the panel's inner shadow gives
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-quick-toc",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Conversation TOC for DeepSeek Harness (DSH): turn-grouped Markdown heading outline with keyword search (title/full-text scope), in-chat match highlighting, auto-follow and smooth jump navigation",
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"description": "Conversation TOC for DeepSeek Harness (DSH): turn-grouped Markdown heading outline with keyword search (title/full-text scope), in-chat match highlighting, per-level heading filtering, auto-follow and smooth jump navigation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"files": [
|
|
@@ -35,7 +35,8 @@
|
|
|
35
35
|
},
|
|
36
36
|
"compatibility": {
|
|
37
37
|
"dshReleases": {
|
|
38
|
-
"0.1.5-rc.1": "compatible"
|
|
38
|
+
"0.1.5-rc.1": "compatible",
|
|
39
|
+
"0.1.5-rc.2": "compatible"
|
|
39
40
|
}
|
|
40
41
|
}
|
|
41
42
|
},
|