dsh-conversation-navigator 0.2.8 → 0.2.10
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.en.md +29 -5
- package/README.md +29 -5
- package/lib/client.js +236 -10
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -17,17 +17,17 @@ A **DeepSeek Harness (DSH) Web conversation navigator panel**: a turn-folded out
|
|
|
17
17
|
|
|
18
18
|
Browser-only (no host behavior), plain JavaScript, zero build step, zero npm dependencies (buttons/tooltips reuse the official primitives from the DSH kernel seed).
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-

|
|
20
|
+
| Dark mode | Light mode |
|
|
21
|
+
| :---: | :---: |
|
|
22
|
+
|  |  |
|
|
24
23
|
|
|
25
|
-
> Three forms: full, hidden, and minimal-left (minimal-right is shelved as of 0.2.6, code retained). The minimal strips collapse to the in-row indicator bars; hover expands the positioning panel. More screenshots in [assets/screenshots](./assets/screenshots).
|
|
24
|
+
> The conversation navigator in dark and light mode. Three forms: full, hidden, and minimal-left (minimal-right is shelved as of 0.2.6, code retained). The minimal strips collapse to the in-row indicator bars; hover expands the positioning panel. More screenshots in [assets/screenshots](./assets/screenshots).
|
|
26
25
|
|
|
27
26
|
## Features
|
|
28
27
|
|
|
29
28
|
- **Turn-folded outline**: shows "Turn N + your question" rows by default, long conversations at a glance
|
|
30
29
|
- **Keyword filter**: click the top-left search icon to reveal the inline input; matches only your questions + the assistant's actual reply text (context, tool calls, commands, compaction and reasoning never match); hits are highlighted and the list text is windowed to the keyword
|
|
30
|
+
- **Jump straight to the hit**: clicking a search result scrolls to where the keyword actually appears in the conversation (not just the top of the message) and marks every occurrence inside that message — a brief flash that eases into a soft persistent tint, so long replies no longer need hunting
|
|
31
31
|
- **Expand / collapse steps**: the arrow button on the right of each turn row (`▸ N`, rotates to `▾` when expanded) smoothly expands or collapses the turn's step details (assistant replies, tool calls, commands, compaction points, etc.)
|
|
32
32
|
- **Hover full text**: rest the mouse on a turn row and a bubble shows the user's complete question, no longer truncated to one line
|
|
33
33
|
- **Full / Hidden / Minimal-left modes**: the header toggle cycles three states —
|
|
@@ -96,6 +96,19 @@ Edit `lib/client.js` and restart `dsh web`. Docked state, dragged position, mini
|
|
|
96
96
|
- **Version-sensitive points**: `[data-chat-anchor-key]` / `[data-conversation-scroll]` are the current DOM anchor conventions of the DSH chat view (verified still present in official v0.1.2-rc.1); if they change after a DSH upgrade, only `findAnchor` / `computeActiveKey` in `lib/client.js` need adjusting
|
|
97
97
|
- No hard `timer` dependency: the client timer service is used for throttling when present, and degrades to unthrottled otherwise
|
|
98
98
|
|
|
99
|
+
## Host ↔ plugin version
|
|
100
|
+
|
|
101
|
+
Official v0.1.2-rc.1 made a breaking change to the conversation data layer (conversation nodes moved from the `session.chat` snapshot to the `uiConversation` chat target snapshot), so the plugin forks by host version:
|
|
102
|
+
|
|
103
|
+
| Host `dsh-client-ui-conversation` | Matching plugin version | npm tag |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| **≥ 0.1.2-rc.1** (has the `uiConversation` service) | **0.2.6 and later** (currently 0.2.10) | `latest` (default) |
|
|
106
|
+
| **≤ 0.1.1-rc.2** (no `uiConversation`, legacy `session.chat` snapshot) | **0.2.5 and earlier** | `legacy` |
|
|
107
|
+
|
|
108
|
+
- New hosts can install the latest normally (`dsh plugin --profile web add dsh-conversation-navigator`).
|
|
109
|
+
- Legacy hosts should pin the legacy tag: `npm i dsh-conversation-navigator@legacy` (= 0.2.5).
|
|
110
|
+
- From 0.2.6 the `peerDependencies` range is `>=0.1.2-rc.1 <0.2.0` (new host only); 0.2.5 and earlier declare no such range.
|
|
111
|
+
|
|
99
112
|
## Structure
|
|
100
113
|
|
|
101
114
|
```
|
|
@@ -108,6 +121,17 @@ cordis.patch.yml # bundle patch layer (inserts the plugin row)
|
|
|
108
121
|
example.patch.yml # patch example for manual installs
|
|
109
122
|
```
|
|
110
123
|
|
|
124
|
+
## Traffic
|
|
125
|
+
|
|
126
|
+
Window **2026-08-17 → 2026-09-20** (refreshed daily by GitHub Actions):
|
|
127
|
+
|
|
128
|
+
| npm downloads | GitHub unique visitors | GitHub unique cloners | Stars | Forks |
|
|
129
|
+
| ---: | ---: | ---: | ---: | ---: |
|
|
130
|
+
| 6,206 | 540 | 335 | 23 | 1 |
|
|
131
|
+
|
|
132
|
+
- Details: [traffic-report.md](./docs/traffic/traffic-report.md) | raw archive: [traffic-archive.json](./docs/traffic/traffic-archive.json) | chart & docs: [docs/traffic](./docs/traffic/README.md)
|
|
133
|
+
- Caveats: npm downloads include mirrors, CI and reinstalls, with a 1–2 day settlement lag; GitHub unique visitors / cloners are closer to real user counts.
|
|
134
|
+
|
|
111
135
|
## License
|
|
112
136
|
|
|
113
137
|
MIT
|
package/README.md
CHANGED
|
@@ -17,17 +17,17 @@
|
|
|
17
17
|
|
|
18
18
|
纯浏览器插件(无宿主行为)、纯 JavaScript、零构建、零 npm 依赖(按钮/Tooltip 复用 DSH 内核 seed 的官方 primitives)。
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-

|
|
20
|
+
| 深色模式 | 浅色模式 |
|
|
21
|
+
| :---: | :---: |
|
|
22
|
+
|  |  |
|
|
24
23
|
|
|
25
|
-
>
|
|
24
|
+
> 深色 / 浅色模式下的会话导航面板。三种形态:显示轮次、隐藏轮次、极简·左(极简·右自 0.2.6 暂缓,代码保留;极简收起仅露行内指示条,悬停展开定位面板)。更多截图见 [assets/screenshots](./assets/screenshots)。
|
|
26
25
|
|
|
27
26
|
## 功能
|
|
28
27
|
|
|
29
28
|
- **按轮折叠大纲**:默认只显示"第 N 轮 + 你的问题"列表,长对话一目了然
|
|
30
29
|
- **关键词过滤**:点击左上角搜索图标呼出输入框,只按「你的提问 + 助手实际回复文本」过滤(上下文、工具、命令、压缩、推理等不参与匹配),命中词高亮,列表文本自动定位到关键词处
|
|
30
|
+
- **搜索命中直达**:点击命中的搜索结果,直接滚动到关键词在会话正文中出现的位置(不再只对齐消息顶部),并把该条消息内所有命中处标出来——先亮一下,再平滑淡出为常驻淡色,长回复里不用再自己找
|
|
31
31
|
- **展开/折叠步次**:点击轮次行右侧的箭头按钮(`▸ N`,展开时旋转为 `▾`)平滑展开/折叠该轮的步次明细(助手回复、工具调用、命令、压缩点等)
|
|
32
32
|
- **悬停查看全文**:鼠标在轮次行停留片刻,气泡显示该轮用户提问的完整原文,不再被单行截断
|
|
33
33
|
- **显示 / 隐藏 / 极简·左三种形态**:头部切换按钮三态循环——「显示轮次」为经典分组视图(压缩等系统事件与轮次同级加粗展示)→「隐藏轮次」每行统一为轨迹徽标 + 文本(用户 = 业务蓝、助手 = 紫罗兰、压缩 = 中性灰)→「极简·左」锚定对话区左缘(为 dsh-better-sidebar 让出右侧,并与官方右侧轨道零重叠),再回到「显示轮次」;「极简·右」形态自 0.2.6 起暂缓(与官方 TurnNavigator 轨道视觉重叠),代码保留;
|
|
@@ -93,6 +93,19 @@ dsh plugin --profile web add ./dsh-conversation-navigator-<version>.tgz
|
|
|
93
93
|
- **版本敏感点**:`[data-chat-anchor-key]` / `[data-conversation-scroll]` 是当前 DSH 聊天视图的 DOM 锚点约定(官方 v0.1.2-rc.1 中已验证仍在),DSH 升级后若锚点变化,只需调整 `lib/client.js` 中 `findAnchor` / `computeActiveKey` 两个函数
|
|
94
94
|
- 未声明 `timer` 硬依赖:客户端的 timer 服务存在则用于节流,不存在时自动退化为未节流模式
|
|
95
95
|
|
|
96
|
+
## 宿主版本 ↔ 插件版本
|
|
97
|
+
|
|
98
|
+
官方 v0.1.2-rc.1 对会话数据层做了一次爆破性更新(会话节点从 `session.chat` 快照迁移到 `uiConversation` 的 chat target 快照),因此插件按宿主版本分叉:
|
|
99
|
+
|
|
100
|
+
| 宿主 `dsh-client-ui-conversation` | 对应插件版本 | npm 标签 |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| **≥ 0.1.2-rc.1**(有 `uiConversation` 服务) | **0.2.6 及以后**(当前 0.2.10) | `latest`(默认) |
|
|
103
|
+
| **≤ 0.1.1-rc.2**(无 `uiConversation`,旧 `session.chat` 快照) | **0.2.5 及以前** | `legacy` |
|
|
104
|
+
|
|
105
|
+
- 新宿主直接安装最新版即可(`dsh plugin --profile web add dsh-conversation-navigator`)。
|
|
106
|
+
- 旧宿主请指定旧标签:`npm i dsh-conversation-navigator@legacy`(= 0.2.5)。
|
|
107
|
+
- 0.2.6 起 `peerDependencies` 声明为 `>=0.1.2-rc.1 <0.2.0`(仅新宿主);0.2.5 及以前不声明该范围。
|
|
108
|
+
|
|
96
109
|
## 目录结构
|
|
97
110
|
|
|
98
111
|
```
|
|
@@ -105,6 +118,17 @@ cordis.patch.yml # 组合包补丁层(插入插件行)
|
|
|
105
118
|
example.patch.yml # 手动安装时的补丁示例
|
|
106
119
|
```
|
|
107
120
|
|
|
121
|
+
## 流量
|
|
122
|
+
|
|
123
|
+
统计区间 **2026-08-17 → 2026-09-20**(GitHub Actions 每日自动刷新):
|
|
124
|
+
|
|
125
|
+
| npm 下载 | GitHub 独立访客 | GitHub 独立克隆者 | Stars | Forks |
|
|
126
|
+
| ---: | ---: | ---: | ---: | ---: |
|
|
127
|
+
| 6,206 | 540 | 335 | 23 | 1 |
|
|
128
|
+
|
|
129
|
+
- 明细报告:[traffic-report.md](./docs/traffic/traffic-report.md)|原始归档:[traffic-archive.json](./docs/traffic/traffic-archive.json)|趋势图与说明:[docs/traffic](./docs/traffic/README.md)
|
|
130
|
+
- 口径:npm 下载含镜像站、CI 与重复安装,且有 1–2 天结算延迟;GitHub 独立访客 / 独立克隆者更接近真实人数。
|
|
131
|
+
|
|
108
132
|
## License
|
|
109
133
|
|
|
110
134
|
MIT
|
package/lib/client.js
CHANGED
|
@@ -96,6 +96,9 @@ window.__ModuleLoader__.load({
|
|
|
96
96
|
const CSS = `
|
|
97
97
|
.cnvnav-panel {
|
|
98
98
|
position: fixed; z-index: 9999; width: 288px;
|
|
99
|
+
/* 显式 border-box: 面板有 1px 边框, 若按默认 content-box, style.width(内容宽) 会与
|
|
100
|
+
offsetWidth/rect.width(含边框) 差 2px, 拖拽缩放的几何换算会因此逐帧漂移 */
|
|
101
|
+
box-sizing: border-box;
|
|
99
102
|
display: flex; flex-direction: column;
|
|
100
103
|
pointer-events: auto;
|
|
101
104
|
background: var(--dsw-alias-bg-overlay, #fff);
|
|
@@ -229,6 +232,7 @@ window.__ModuleLoader__.load({
|
|
|
229
232
|
background: color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f59e0b) 30%, transparent);
|
|
230
233
|
color: inherit; border-radius: 2px; padding: 0 1px; font-weight: 600;
|
|
231
234
|
}
|
|
235
|
+
|
|
232
236
|
.cnvnav-empty { padding: 18px 10px; text-align: center; color: var(--dsw-alias-label-secondary, #888); }
|
|
233
237
|
/* ---------- 极简模式 ---------- */
|
|
234
238
|
.cnvnav-mini-wrap {
|
|
@@ -908,13 +912,223 @@ window.__ModuleLoader__.load({
|
|
|
908
912
|
return null;
|
|
909
913
|
}
|
|
910
914
|
let lastActive = null;
|
|
911
|
-
|
|
915
|
+
/* ---------- 会话正文内的命中定位(点击搜索命中后把关键词标在正文里) ---------- */
|
|
916
|
+
const FIND_STEPS = 28; /* 淡出用的预置层数(层名 cnvnav-find-s0 … s27) */
|
|
917
|
+
const FIND_HOT_PCT = 55; /* 命中瞬间的背景浓度(%) */
|
|
918
|
+
const FIND_DIM_PCT = 22; /* 最终常驻的淡色浓度(%) */
|
|
919
|
+
const FIND_HOLD_MS = 300; /* 最亮保持时长 */
|
|
920
|
+
const FIND_FADE_MS = 900; /* 变淡时长(easeOutCubic 逐帧换层) */
|
|
921
|
+
const FIND_MAX_RANGES = 200;
|
|
922
|
+
const FIND_BLOCK_TAGS = { P: 1, DIV: 1, LI: 1, UL: 1, OL: 1, PRE: 1, BLOCKQUOTE: 1, TD: 1, TH: 1, TR: 1, TABLE: 1, SECTION: 1, ARTICLE: 1, H1: 1, H2: 1, H3: 1, H4: 1, H5: 1, H6: 1 };
|
|
923
|
+
let findRaf = null; /* 淡出动画句柄 */
|
|
924
|
+
let findStep = null; /* 当前注册的层号 */
|
|
925
|
+
let findMark = null; /* 当前高亮的 Highlight 对象(各层共用同一份 Range) */
|
|
926
|
+
function findLayerName(step) {
|
|
927
|
+
return "cnvnav-find-s" + step;
|
|
928
|
+
}
|
|
929
|
+
function findAlphaPct(step) {
|
|
930
|
+
const k = FIND_STEPS <= 1 ? 1 : step / (FIND_STEPS - 1);
|
|
931
|
+
return FIND_HOT_PCT + (FIND_DIM_PCT - FIND_HOT_PCT) * k;
|
|
932
|
+
}
|
|
933
|
+
/* ::highlight 伪元素不支持 transition/animation, 只能预置多层不同浓度逐帧切换 */
|
|
934
|
+
function findFadeCss() {
|
|
935
|
+
let out = "\n/* 会话正文内的命中高亮: 逐帧换层实现平滑淡出(::highlight 不支持 transition) */";
|
|
936
|
+
for (let i = 0; i < FIND_STEPS; i++) {
|
|
937
|
+
out += "\n::highlight(" + findLayerName(i) + ") {\n background: color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f59e0b) " + findAlphaPct(i).toFixed(1) + "%, transparent);\n color: inherit;\n}";
|
|
938
|
+
}
|
|
939
|
+
return out;
|
|
940
|
+
}
|
|
941
|
+
(function injectFindFadeCss() {
|
|
942
|
+
try {
|
|
943
|
+
const el = document.createElement("style");
|
|
944
|
+
el.textContent = findFadeCss();
|
|
945
|
+
document.head.appendChild(el);
|
|
946
|
+
} catch (err) { console.error(err); }
|
|
947
|
+
})();
|
|
948
|
+
/* 模块顶部的 const CSS 是插件自己的样式字符串, 会遮蔽浏览器全局 CSS, 故走 globalThis */
|
|
949
|
+
function highlightApi() {
|
|
950
|
+
try {
|
|
951
|
+
const api = typeof globalThis === "undefined" ? undefined : globalThis.CSS;
|
|
952
|
+
if (api === undefined || api === null || api.highlights === undefined) return null;
|
|
953
|
+
return api.highlights;
|
|
954
|
+
} catch (err) { return null; }
|
|
955
|
+
}
|
|
956
|
+
function highlightCtor() {
|
|
957
|
+
try {
|
|
958
|
+
return typeof globalThis === "undefined" ? undefined : globalThis.Highlight;
|
|
959
|
+
} catch (err) { return undefined; }
|
|
960
|
+
}
|
|
961
|
+
function dropFindLayer() {
|
|
962
|
+
const store = highlightApi();
|
|
963
|
+
if (store !== null && findStep !== null) store.delete(findLayerName(findStep));
|
|
964
|
+
findStep = null;
|
|
965
|
+
}
|
|
966
|
+
function showFindStep(step) {
|
|
967
|
+
const store = highlightApi();
|
|
968
|
+
if (store === null || findMark === null) return;
|
|
969
|
+
const next = Math.max(0, Math.min(FIND_STEPS - 1, step));
|
|
970
|
+
if (next === findStep) return;
|
|
971
|
+
if (findStep !== null) store.delete(findLayerName(findStep));
|
|
972
|
+
store.set(findLayerName(next), findMark);
|
|
973
|
+
findStep = next;
|
|
974
|
+
}
|
|
975
|
+
function stopFindFade() {
|
|
976
|
+
if (findRaf !== null) {
|
|
977
|
+
if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(findRaf);
|
|
978
|
+
findRaf = null;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
function startFindFade() {
|
|
982
|
+
stopFindFade();
|
|
983
|
+
if (typeof requestAnimationFrame !== "function") { showFindStep(FIND_STEPS - 1); return; }
|
|
984
|
+
const now = () => (typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now());
|
|
985
|
+
const started = now();
|
|
986
|
+
const tick = () => {
|
|
987
|
+
const t = now() - started;
|
|
988
|
+
if (t <= FIND_HOLD_MS) showFindStep(0);
|
|
989
|
+
else if (t >= FIND_HOLD_MS + FIND_FADE_MS) { showFindStep(FIND_STEPS - 1); findRaf = null; return; }
|
|
990
|
+
else {
|
|
991
|
+
const u = (t - FIND_HOLD_MS) / FIND_FADE_MS;
|
|
992
|
+
const eased = 1 - Math.pow(1 - u, 3); /* easeOutCubic */
|
|
993
|
+
showFindStep(Math.round(eased * (FIND_STEPS - 1)));
|
|
994
|
+
}
|
|
995
|
+
findRaf = requestAnimationFrame(tick);
|
|
996
|
+
};
|
|
997
|
+
findRaf = requestAnimationFrame(tick);
|
|
998
|
+
}
|
|
999
|
+
function clearFindHighlight() {
|
|
1000
|
+
try {
|
|
1001
|
+
stopFindFade();
|
|
1002
|
+
dropFindLayer();
|
|
1003
|
+
findMark = null;
|
|
1004
|
+
} catch (err) { console.error(err); }
|
|
1005
|
+
}
|
|
1006
|
+
/* 最近的块级祖先: 跨块边界补一个分隔符, 避免两段文字被误判成一个词 */
|
|
1007
|
+
function findBlockOf(node) {
|
|
1008
|
+
let el = node.parentElement;
|
|
1009
|
+
while (el !== null && el !== undefined) {
|
|
1010
|
+
if (FIND_BLOCK_TAGS[el.tagName] === 1) return el;
|
|
1011
|
+
el = el.parentElement;
|
|
1012
|
+
}
|
|
1013
|
+
return null;
|
|
1014
|
+
}
|
|
1015
|
+
/* 收集锚点子树内可见的文本节点(跳过脚本/样式) */
|
|
1016
|
+
function collectFindTextNodes(root) {
|
|
1017
|
+
const out = [];
|
|
1018
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
|
1019
|
+
acceptNode(node) {
|
|
1020
|
+
const text = node.nodeValue;
|
|
1021
|
+
if (text === null || text === "" || text.trim() === "") return NodeFilter.FILTER_REJECT;
|
|
1022
|
+
const parent = node.parentElement;
|
|
1023
|
+
if (parent === null || parent === undefined) return NodeFilter.FILTER_REJECT;
|
|
1024
|
+
const tag = parent.tagName;
|
|
1025
|
+
if (tag === "SCRIPT" || tag === "STYLE" || tag === "NOSCRIPT" || tag === "TEXTAREA") return NodeFilter.FILTER_REJECT;
|
|
1026
|
+
return NodeFilter.FILTER_ACCEPT;
|
|
1027
|
+
},
|
|
1028
|
+
});
|
|
1029
|
+
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) out.push(node);
|
|
1030
|
+
return out;
|
|
1031
|
+
}
|
|
1032
|
+
/* 归一化索引: 连续空白折叠成单个空格; map[i] = [文本节点, 节点内偏移] */
|
|
1033
|
+
function buildFindIndex(root) {
|
|
1034
|
+
const nodes = collectFindTextNodes(root);
|
|
1035
|
+
let text = "";
|
|
1036
|
+
const map = [];
|
|
1037
|
+
let prevBlock = null;
|
|
1038
|
+
let pendingSpace = false;
|
|
1039
|
+
for (const node of nodes) {
|
|
1040
|
+
const value = node.nodeValue;
|
|
1041
|
+
const block = findBlockOf(node);
|
|
1042
|
+
if (text !== "" && (block === null || block !== prevBlock)) pendingSpace = true;
|
|
1043
|
+
prevBlock = block;
|
|
1044
|
+
for (let i = 0; i < value.length; i++) {
|
|
1045
|
+
const ch = value.charAt(i);
|
|
1046
|
+
if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v") { pendingSpace = text !== ""; continue; }
|
|
1047
|
+
if (pendingSpace) { text += " "; map.push([node, i]); pendingSpace = false; }
|
|
1048
|
+
text += ch;
|
|
1049
|
+
map.push([node, i]);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
return { text, map };
|
|
1053
|
+
}
|
|
1054
|
+
function findNeedle(kw) {
|
|
1055
|
+
return String(kw).replace(/\s+/g, " ").trim().toLowerCase();
|
|
1056
|
+
}
|
|
1057
|
+
/* 关键词在 DOM 里的每一处出现 → Range(最多 FIND_MAX_RANGES 处) */
|
|
1058
|
+
function findKeywordRanges(root, needle) {
|
|
1059
|
+
const ranges = [];
|
|
1060
|
+
if (needle === "") return ranges;
|
|
1061
|
+
const index = buildFindIndex(root);
|
|
1062
|
+
if (index.text === "") return ranges;
|
|
1063
|
+
let hay = index.text.toLowerCase();
|
|
1064
|
+
if (hay.length !== index.text.length) hay = index.text; /* 极端 Unicode 大小写长度变化时退回原串 */
|
|
1065
|
+
let from = 0;
|
|
1066
|
+
for (;;) {
|
|
1067
|
+
const at = hay.indexOf(needle, from);
|
|
1068
|
+
if (at === -1) break;
|
|
1069
|
+
const start = index.map[at];
|
|
1070
|
+
const end = index.map[at + needle.length - 1];
|
|
1071
|
+
if (start !== undefined && end !== undefined) {
|
|
1072
|
+
try {
|
|
1073
|
+
const range = document.createRange();
|
|
1074
|
+
range.setStart(start[0], start[1]);
|
|
1075
|
+
range.setEnd(end[0], end[1] + 1);
|
|
1076
|
+
ranges.push(range);
|
|
1077
|
+
} catch (err) { /* 节点已被宿主重渲染替换: 跳过这一处 */ }
|
|
1078
|
+
}
|
|
1079
|
+
from = at + needle.length;
|
|
1080
|
+
if (ranges.length >= FIND_MAX_RANGES) break;
|
|
1081
|
+
}
|
|
1082
|
+
return ranges;
|
|
1083
|
+
}
|
|
1084
|
+
/* 把首个命中滚到视口偏上处(而不是消息顶部) */
|
|
1085
|
+
function scrollRangeIntoView(range) {
|
|
1086
|
+
try {
|
|
1087
|
+
const rect = range.getBoundingClientRect();
|
|
1088
|
+
const scrollport = document.querySelector("[data-conversation-scroll]");
|
|
1089
|
+
if (scrollport !== null && scrollport !== undefined) {
|
|
1090
|
+
const box = scrollport.getBoundingClientRect();
|
|
1091
|
+
const delta = rect.top - box.top - Math.max(72, box.height * 0.32);
|
|
1092
|
+
if (typeof scrollport.scrollBy === "function") scrollport.scrollBy({ top: delta, behavior: "smooth" });
|
|
1093
|
+
else scrollport.scrollTop += delta;
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
const host = range.startContainer;
|
|
1097
|
+
const el = host.nodeType === 1 ? host : host.parentElement;
|
|
1098
|
+
if (el !== null && el !== undefined && typeof el.scrollIntoView === "function") el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
1099
|
+
} catch (err) { console.error(err); }
|
|
1100
|
+
}
|
|
1101
|
+
/* 正文内定位并高亮: 先最亮, 再 easeOut 逐帧变淡到常驻淡色; 返回是否成功 */
|
|
1102
|
+
function locateInConversation(row, kw) {
|
|
1103
|
+
clearFindHighlight();
|
|
1104
|
+
try {
|
|
1105
|
+
const store = highlightApi();
|
|
1106
|
+
const Ctor = highlightCtor();
|
|
1107
|
+
if (store === null || typeof Ctor !== "function") return false;
|
|
1108
|
+
const needle = findNeedle(kw);
|
|
1109
|
+
const ranges = findKeywordRanges(row, needle);
|
|
1110
|
+
if (ranges.length === 0) return false;
|
|
1111
|
+
findMark = new Ctor(...ranges);
|
|
1112
|
+
showFindStep(0);
|
|
1113
|
+
startFindFade();
|
|
1114
|
+
scrollRangeIntoView(ranges[0]);
|
|
1115
|
+
return true;
|
|
1116
|
+
} catch (err) {
|
|
1117
|
+
console.error(err);
|
|
1118
|
+
clearFindHighlight();
|
|
1119
|
+
return false;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
function jumpTo(key, kw) {
|
|
912
1123
|
try {
|
|
913
1124
|
const el = findAnchor(key);
|
|
914
1125
|
if (el === null) return false;
|
|
915
|
-
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
916
1126
|
lastActive = key;
|
|
917
1127
|
setState({ activeKey: key });
|
|
1128
|
+
/* 搜索命中: 直接定位到关键词所在处并在正文里高亮; 其余情况退回消息顶部 */
|
|
1129
|
+
if (typeof kw === "string" && kw !== "" && locateInConversation(el, kw)) return true;
|
|
1130
|
+
clearFindHighlight();
|
|
1131
|
+
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
918
1132
|
return true;
|
|
919
1133
|
} catch (err) {
|
|
920
1134
|
console.error(err);
|
|
@@ -1092,6 +1306,7 @@ window.__ModuleLoader__.load({
|
|
|
1092
1306
|
}
|
|
1093
1307
|
function toggleSearch() {
|
|
1094
1308
|
if (searchOpen) setFilter("");
|
|
1309
|
+
clearFindHighlight();
|
|
1095
1310
|
setSearchOpen(!searchOpen);
|
|
1096
1311
|
}
|
|
1097
1312
|
/* ---------- 拖动定位(仅面板形式, 极简条不参与) ---------- */
|
|
@@ -1156,11 +1371,16 @@ window.__ModuleLoader__.load({
|
|
|
1156
1371
|
if (resizeStart !== null) return;
|
|
1157
1372
|
const el = panelEl;
|
|
1158
1373
|
if (el === null) return;
|
|
1374
|
+
/* 锚定拖动开始时的右边缘/上边缘: 之后每帧都基于它换算 left/高度上限。
|
|
1375
|
+
绝不能用实时 rect 反推(老实现如此), 否则「请求宽度 ≠ 实际渲染宽度」的
|
|
1376
|
+
差值会逐帧累积, 表现为任何方向的鼠标位移都把面板往右推。 */
|
|
1377
|
+
const rect = el.getBoundingClientRect();
|
|
1159
1378
|
resizeStart = {
|
|
1160
1379
|
startX: e.clientX, startY: e.clientY,
|
|
1161
1380
|
baseW: el.offsetWidth || 288,
|
|
1162
1381
|
baseH: el.offsetHeight || 200,
|
|
1163
|
-
|
|
1382
|
+
anchorRight: rect.right,
|
|
1383
|
+
anchorTop: rect.top,
|
|
1164
1384
|
el: el,
|
|
1165
1385
|
};
|
|
1166
1386
|
try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) { /* 忽略 */ }
|
|
@@ -1175,7 +1395,6 @@ window.__ModuleLoader__.load({
|
|
|
1175
1395
|
以拖拽起点为基准线性跟随鼠标(无漂移/无状态跳变):
|
|
1176
1396
|
拖小立即压缩出滚动, 拖大在内容充足时线性恢复更多行;
|
|
1177
1397
|
内容不足时面板保持内容高(拖动无感, 属预期)。 */
|
|
1178
|
-
const r = d.el.getBoundingClientRect();
|
|
1179
1398
|
const sp = document.querySelector("[data-conversation-scroll]");
|
|
1180
1399
|
/* 上限必须与 place() 各分支一致, 否则松手后会被 place 压回造成跳变:
|
|
1181
1400
|
docked/常规 = min(容器高-24, vh-40); 自由位置 = vh-顶-8 */
|
|
@@ -1183,14 +1402,15 @@ window.__ModuleLoader__.load({
|
|
|
1183
1402
|
const cap = Math.max(minPanelH(), Math.round(
|
|
1184
1403
|
state.docked && sr !== null
|
|
1185
1404
|
? Math.min(sr.height - 24, vh - 40)
|
|
1186
|
-
: vh -
|
|
1405
|
+
: vh - d.anchorTop - 8
|
|
1187
1406
|
));
|
|
1188
1407
|
const nw = Math.min(Math.max(240, d.baseW - dx), 560);
|
|
1189
1408
|
/* 目标上限 = 起点可见高 + 总位移, 线性且可逆 */
|
|
1190
1409
|
const nMaxH = Math.min(Math.max(minPanelH(), d.baseH + dy), cap);
|
|
1191
|
-
|
|
1410
|
+
/* 右边缘钉在起点位置(而不是实时 rect): 拖动过程中面板绝不平移 */
|
|
1411
|
+
const left = Math.max(8, Math.round(d.anchorRight - nw));
|
|
1192
1412
|
d.el.style.width = Math.round(nw) + "px";
|
|
1193
|
-
d.el.style.left =
|
|
1413
|
+
d.el.style.left = left + "px";
|
|
1194
1414
|
d.el.style.height = "";
|
|
1195
1415
|
d.el.style.maxHeight = Math.round(nMaxH) + "px";
|
|
1196
1416
|
}
|
|
@@ -1220,6 +1440,10 @@ window.__ModuleLoader__.load({
|
|
|
1220
1440
|
const panel = panelEl;
|
|
1221
1441
|
if (panel === null) return false;
|
|
1222
1442
|
if (dragMove !== null) return true;
|
|
1443
|
+
/* 缩放中同样不接管: place() 会按"持久化的旧宽高"重写 width/left/maxHeight,
|
|
1444
|
+
若在拖拽缩放过程中触发(400ms 复查 / ResizeObserver / window.resize),
|
|
1445
|
+
就会把用户正在拖的几何值顶掉。 */
|
|
1446
|
+
if (resizeStart !== null) return true;
|
|
1223
1447
|
const vw = document.documentElement.clientWidth || 1000;
|
|
1224
1448
|
const vh = document.documentElement.clientHeight || 800;
|
|
1225
1449
|
/* 新宿主 dock 右侧需让出官方 rail 的 28px 竖带(right: calc(12px - clearance) + width:28px),
|
|
@@ -1492,7 +1716,7 @@ window.__ModuleLoader__.load({
|
|
|
1492
1716
|
ref: (el) => { itemEls[item.key] = el; },
|
|
1493
1717
|
className: "cnvnav-item" + (item.key === snap.activeKey ? " cnvnav-item-active" : "") + (staggerMs !== undefined ? " cnvnav-fade-item" : ""),
|
|
1494
1718
|
style: staggerMs !== undefined ? { animationDelay: staggerMs + "ms" } : undefined,
|
|
1495
|
-
onClick: () => jumpTo(item.key),
|
|
1719
|
+
onClick: () => jumpTo(item.key, filtering && hit(item.search) ? kw : null),
|
|
1496
1720
|
},
|
|
1497
1721
|
React.createElement("span", { className: "cnvnav-badge cnvnav-badge-" + item.cls }, item.label),
|
|
1498
1722
|
hasPreview
|
|
@@ -1509,6 +1733,8 @@ window.__ModuleLoader__.load({
|
|
|
1509
1733
|
const useSnippet = filtering && hit(headSearch);
|
|
1510
1734
|
const sub = useSnippet ? snippetNodes(headSearch, kw, 5, 55) : g.subtitle;
|
|
1511
1735
|
const hasSub = sub !== "" && (!Array.isArray(sub) || sub.length > 0);
|
|
1736
|
+
/* 组头自身命中时, 点击同样在正文里定位关键词 */
|
|
1737
|
+
const headKw = filtering && g.headIsUser && g.items.length > 0 && hit(g.items[0].search) ? kw : null;
|
|
1512
1738
|
/* 标题策略:
|
|
1513
1739
|
显示轮次: 轮次行 = 加粗「第 N 轮」; 压缩等非轮次行 = 加粗类型标签, 视作轮次同级
|
|
1514
1740
|
隐藏轮次: 每行统一轨迹徽标 — 轮次头 = 「用户」业务蓝(助手开头则助手紫), 非轮次行 = 各自徽标(压缩=中性灰) */
|
|
@@ -1545,7 +1771,7 @@ window.__ModuleLoader__.load({
|
|
|
1545
1771
|
React.createElement("button", {
|
|
1546
1772
|
type: "button",
|
|
1547
1773
|
className: "cnvnav-group-head",
|
|
1548
|
-
onClick: () => jumpTo(g.headKey),
|
|
1774
|
+
onClick: () => jumpTo(g.headKey, headKw),
|
|
1549
1775
|
},
|
|
1550
1776
|
titleNode,
|
|
1551
1777
|
hasSub
|
|
@@ -1690,7 +1916,7 @@ window.__ModuleLoader__.load({
|
|
|
1690
1916
|
placeholder: "请输入关键词",
|
|
1691
1917
|
value: filter,
|
|
1692
1918
|
autoFocus: true,
|
|
1693
|
-
onChange: (e) => setFilter(e.target.value),
|
|
1919
|
+
onChange: (e) => { clearFindHighlight(); setFilter(e.target.value); },
|
|
1694
1920
|
})
|
|
1695
1921
|
: null,
|
|
1696
1922
|
searchOpen
|