dsh-quick-toc 0.2.0-beta → 0.2.2

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.
Files changed (2) hide show
  1. package/lib/client.js +239 -25
  2. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -135,6 +135,18 @@ window.__ModuleLoader__.load({
135
135
  return null;
136
136
  }
137
137
 
138
+ // count every occurrence of q (case-insensitive) inside text
139
+ function countOccurrences(text, q) {
140
+ var lower = text.toLowerCase();
141
+ var count = 0;
142
+ var idx = 0;
143
+ while ((idx = lower.indexOf(q, idx)) !== -1) {
144
+ count++;
145
+ idx += q.length;
146
+ }
147
+ return count;
148
+ }
149
+
138
150
  // find the conversation's "load older messages" button (scoped to the
139
151
  // conversation scrollport so panel buttons are never matched)
140
152
  function findLoadOlderButton() {
@@ -148,6 +160,58 @@ window.__ModuleLoader__.load({
148
160
  return null;
149
161
  }
150
162
 
163
+ // ---- search keyword highlight in the conversation ----
164
+ var highlightSpans = [];
165
+
166
+ function clearHighlights() {
167
+ for (var i = 0; i < highlightSpans.length; i++) {
168
+ var s = highlightSpans[i];
169
+ if (s.parentNode) {
170
+ var t = document.createTextNode(s.textContent);
171
+ s.parentNode.replaceChild(t, s);
172
+ }
173
+ }
174
+ highlightSpans = [];
175
+ }
176
+
177
+ // wrap every occurrence of q (case-insensitive) inside row's text nodes;
178
+ // the occurrence at `currentOcc` gets a distinct "current" highlight
179
+ function highlightRow(row, q, currentOcc) {
180
+ if (!row || !q) return;
181
+ var walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT, null);
182
+ var textNodes = [];
183
+ while (walker.nextNode()) textNodes.push(walker.currentNode);
184
+ var occ = 0;
185
+ for (var i = 0; i < textNodes.length; i++) {
186
+ var node = textNodes[i];
187
+ var text = node.nodeValue;
188
+ if (!text) continue;
189
+ var lower = text.toLowerCase();
190
+ var idx = lower.indexOf(q);
191
+ if (idx < 0) continue;
192
+ var frag = document.createDocumentFragment();
193
+ var before = text.slice(0, idx);
194
+ if (before) frag.appendChild(document.createTextNode(before));
195
+ var mark = document.createElement("span");
196
+ if (occ === currentOcc) {
197
+ mark.className = "dqt-current";
198
+ mark.style.background = "rgba(79,140,255,0.55)";
199
+ mark.style.boxShadow = "0 0 0 1px rgba(79,140,255,0.85)";
200
+ } else {
201
+ mark.style.background = "rgba(79,140,255,0.32)";
202
+ }
203
+ mark.style.borderRadius = "2px";
204
+ mark.style.color = "inherit";
205
+ mark.textContent = text.slice(idx, idx + q.length);
206
+ frag.appendChild(mark);
207
+ highlightSpans.push(mark);
208
+ occ++;
209
+ var after = text.slice(idx + q.length);
210
+ if (after) frag.appendChild(document.createTextNode(after));
211
+ node.parentNode.replaceChild(frag, node);
212
+ }
213
+ }
214
+
151
215
  // ---------- theme colors: DSH CSS variables (adapt to light/dark) ----------
152
216
  var C = {
153
217
  panelBg: "var(--dsw-alias-bg-base, rgba(24, 28, 36, 0.96))",
@@ -343,6 +407,22 @@ window.__ModuleLoader__.load({
343
407
  var searching = _s17[0];
344
408
  var setSearching = _s17[1];
345
409
  var searchTimerRef = react.useRef(null);
410
+ // search scope: "title" (default) or "full" (user messages + AI replies)
411
+ var _s18 = react.useState("title");
412
+ var searchScope = _s18[0];
413
+ var setSearchScope = _s18[1];
414
+ // previous scope kept briefly so the old label cross-fades with the new one
415
+ var _s19 = react.useState(null);
416
+ var prevScope = _s19[0];
417
+ var setPrevScope = _s19[1];
418
+ var scopeTimerRef = react.useRef(null);
419
+ var toggleScope = function () {
420
+ setPrevScope(searchScope);
421
+ setSearchScope(searchScope === "title" ? "full" : "title");
422
+ setMatchIdx(0);
423
+ if (scopeTimerRef.current) clearTimeout(scopeTimerRef.current);
424
+ scopeTimerRef.current = setTimeout(function () { setPrevScope(null); }, 300);
425
+ };
346
426
  var openSearch = function () {
347
427
  setSearchOpen(true);
348
428
  setSearchAnim("enter");
@@ -350,6 +430,7 @@ window.__ModuleLoader__.load({
350
430
  var closeSearch = function () {
351
431
  setSearchAnim("out");
352
432
  setQuery("");
433
+ clearHighlights();
353
434
  setTimeout(function () {
354
435
  setSearchOpen(false);
355
436
  setSearchAnim("hidden");
@@ -369,6 +450,7 @@ window.__ModuleLoader__.load({
369
450
  setQuery(v);
370
451
  setMatchIdx(0);
371
452
  setSearching(true);
453
+ clearHighlights();
372
454
  if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
373
455
  searchTimerRef.current = setTimeout(function () { setSearching(false); }, 220);
374
456
  };
@@ -545,9 +627,10 @@ window.__ModuleLoader__.load({
545
627
  var turnTimes = {};
546
628
  var turnUserKey = {};
547
629
  var turnUserText = {};
630
+ var turnUserFull = {};
548
631
  if (!order || !nodes) return result;
549
632
  // first pass: per-turn time — the LAST message of the turn wins (end time);
550
- // also remember each turn's user message key + first-line preview
633
+ // also remember each turn's user message key + first-line preview + full text
551
634
  for (var i = 0; i < order.length; i++) {
552
635
  var k0 = order[i];
553
636
  var n0 = nodes.get(k0);
@@ -558,11 +641,12 @@ window.__ModuleLoader__.load({
558
641
  if (n0.kind === "user" && turnUserKey[tid0] === undefined) {
559
642
  turnUserKey[tid0] = k0;
560
643
  turnUserText[tid0] = previewText(extractUserText(n0), 30);
644
+ turnUserFull[tid0] = extractUserText(n0);
561
645
  }
562
646
  var t0 = getNodeTime(n0);
563
647
  if (t0) turnTimes[tid0] = t0;
564
648
  }
565
- // second pass: group assistant headings by turn
649
+ // second pass: group assistant headings + full reply texts by turn
566
650
  for (var j = 0; j < order.length; j++) {
567
651
  var key = order[j];
568
652
  var node = nodes.get(key);
@@ -576,11 +660,15 @@ window.__ModuleLoader__.load({
576
660
  time: turnId !== null ? (turnTimes[turnId] || "") : "",
577
661
  userKey: turnId !== null ? (turnUserKey[turnId] || "") : "",
578
662
  userText: turnId !== null ? (turnUserText[turnId] || "") : "",
663
+ userFull: turnId !== null ? (turnUserFull[turnId] || "") : "",
664
+ msgs: [],
579
665
  headings: []
580
666
  };
581
667
  result.push(current);
582
668
  }
583
- var parsed = parseHeadings(extractReplyText(node));
669
+ var text = extractReplyText(node);
670
+ current.msgs.push({ key: key, text: text });
671
+ var parsed = parseHeadings(text);
584
672
  var hIdx = 0;
585
673
  for (var k = 0; k < parsed.length; k++) {
586
674
  current.headings.push({ level: parsed[k].level, title: parsed[k].title, key: key, idx: hIdx });
@@ -661,7 +749,11 @@ window.__ModuleLoader__.load({
661
749
  return function () { sp.removeEventListener("scroll", update); };
662
750
  }, [keyToGroup]);
663
751
 
664
- // ---- search matches: headings whose title contains the query ----
752
+ // ---- search matches ----
753
+ // "title" scope: heading titles only; "full" scope: also the user
754
+ // message and every AI reply text of each turn.
755
+ // Every occurrence counts (multiple hits inside one message = multiple
756
+ // matches), so the n/N counter reflects the real total.
665
757
  var matches = react.useMemo(function () {
666
758
  var q = query.trim().toLowerCase();
667
759
  if (!q) return [];
@@ -670,13 +762,30 @@ window.__ModuleLoader__.load({
670
762
  var g = groups[gi];
671
763
  for (var j = 0; j < g.headings.length; j++) {
672
764
  var h = g.headings[j];
673
- if (h.title.toLowerCase().indexOf(q) >= 0) {
765
+ var n = countOccurrences(h.title, q);
766
+ for (var c = 0; c < n; c++) {
674
767
  out.push({ gi: gi, title: h.title, key: h.key, idx: h.idx });
675
768
  }
676
769
  }
770
+ if (searchScope === "full") {
771
+ if (g.userKey && g.userFull) {
772
+ var nu = countOccurrences(g.userFull, q);
773
+ for (var cu = 0; cu < nu; cu++) {
774
+ out.push({ gi: gi, title: previewText(g.userFull, 30), key: g.userKey, idx: undefined });
775
+ }
776
+ }
777
+ for (var m = 0; m < g.msgs.length; m++) {
778
+ var msg = g.msgs[m];
779
+ if (!msg.text) continue;
780
+ var nm = countOccurrences(msg.text, q);
781
+ for (var cm = 0; cm < nm; cm++) {
782
+ out.push({ gi: gi, title: previewText(msg.text, 30), key: msg.key, idx: undefined });
783
+ }
784
+ }
785
+ }
677
786
  }
678
787
  return out;
679
- }, [groups, query]);
788
+ }, [groups, query, searchScope]);
680
789
 
681
790
  // ---- nothing to show without headings ----
682
791
  if (groups.length === 0) return null;
@@ -754,13 +863,38 @@ window.__ModuleLoader__.load({
754
863
  sp.scrollTo({ top: t, behavior: "smooth" });
755
864
  };
756
865
 
757
- // jump to the n-th match, cycling; also reveal the group in the outline
866
+ // jump to the n-th match, cycling; also reveal the group in the outline.
867
+ // ONE smooth scroll straight to the current occurrence (no competing
868
+ // scrolls): highlight first, then position the mark at the upper-middle
758
869
  var goToMatch = function (n) {
759
870
  if (matches.length === 0) return;
760
871
  var i = ((n % matches.length) + matches.length) % matches.length;
761
872
  setMatchIdx(i);
762
873
  var m = matches[i];
763
- jump(m.key, m.idx);
874
+ var q = query.trim().toLowerCase();
875
+ clearHighlights();
876
+ var r = findRowStrict(m.key);
877
+ if (r && q) {
878
+ // occurrence index within the target message (consecutive in matches)
879
+ var occ = 0;
880
+ for (var p = i - 1; p >= 0 && matches[p].key === m.key; p--) occ++;
881
+ highlightRow(r, q, occ);
882
+ var markEl = r.querySelector(".dqt-current");
883
+ var sp = r.closest ? r.closest("[data-conversation-scroll]") : null;
884
+ if (sp && markEl) {
885
+ var target = markEl.getBoundingClientRect().top - sp.getBoundingClientRect().top + sp.scrollTop - sp.clientHeight * 0.35;
886
+ // stick-to-bottom nudge (same as jump): break the 25px floor hold
887
+ var floor = Math.max(0, sp.scrollHeight - sp.clientHeight);
888
+ if (floor - sp.scrollTop <= 25 && Math.abs(target - sp.scrollTop) > 60) {
889
+ sp.scrollTop = Math.max(0, floor - 26);
890
+ }
891
+ sp.scrollTo({ top: Math.max(0, target), behavior: "smooth" });
892
+ } else {
893
+ jump(m.key, m.idx);
894
+ }
895
+ } else {
896
+ jump(m.key, m.idx);
897
+ }
764
898
  setVisibleCount(function (prev) {
765
899
  return Math.max(prev, Math.min(groups.length, groups.length - m.gi));
766
900
  });
@@ -1043,26 +1177,69 @@ window.__ModuleLoader__.load({
1043
1177
  transition: "opacity 0.2s ease, transform 0.2s ease"
1044
1178
  },
1045
1179
  children: [
1046
- react_jsx_runtime.jsx("input", {
1047
- ref: searchRef,
1048
- value: query,
1049
- onChange: function (e) { onQueryChange(e.target.value); },
1050
- onKeyDown: function (e) {
1051
- if (e.key === "Enter") { goToMatch(matchIdx + 1); e.preventDefault(); }
1052
- if (e.key === "Escape") { closeSearch(); }
1053
- },
1054
- placeholder: "搜索标题,回车定位…",
1180
+ // input with a custom animated placeholder: the box stays static,
1181
+ // only the placeholder text fades when the scope switches
1182
+ react_jsx_runtime.jsx("div", {
1055
1183
  style: {
1184
+ position: "relative",
1056
1185
  flex: 1,
1057
1186
  minWidth: 0,
1187
+ display: "flex",
1188
+ alignItems: "center",
1058
1189
  background: "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.14))",
1059
- border: "none",
1060
- borderRadius: 6,
1061
- padding: "3px 8px",
1062
- fontSize: 12,
1063
- color: C.text,
1064
- outline: "none"
1065
- }
1190
+ borderRadius: 999,
1191
+ padding: "6px 12px"
1192
+ },
1193
+ children: [
1194
+ react_jsx_runtime.jsx("input", {
1195
+ ref: searchRef,
1196
+ value: query,
1197
+ onChange: function (e) { onQueryChange(e.target.value); },
1198
+ onKeyDown: function (e) {
1199
+ if (e.key === "Enter") { goToMatch(matchIdx + 1); e.preventDefault(); }
1200
+ if (e.key === "Escape") { closeSearch(); }
1201
+ },
1202
+ style: {
1203
+ flex: 1,
1204
+ minWidth: 0,
1205
+ width: "100%",
1206
+ background: "transparent",
1207
+ border: "none",
1208
+ outline: "none",
1209
+ fontSize: 13,
1210
+ color: C.text,
1211
+ padding: 0
1212
+ }
1213
+ }),
1214
+ (!query) ? react_jsx_runtime.jsx("span", {
1215
+ style: {
1216
+ position: "absolute",
1217
+ left: 12,
1218
+ pointerEvents: "none",
1219
+ fontSize: 13,
1220
+ color: "var(--dsw-alias-label-tertiary, rgba(128,128,128,0.7))"
1221
+ },
1222
+ children: [
1223
+ "搜索",
1224
+ react_jsx_runtime.jsx("span", {
1225
+ style: { position: "relative", display: "inline-block" },
1226
+ children: [
1227
+ react_jsx_runtime.jsx("span", {
1228
+ key: searchScope,
1229
+ style: { display: "inline-block", animation: "dqt-fade-in 0.25s linear" },
1230
+ children: searchScope === "title" ? "标题" : "全文"
1231
+ }),
1232
+ (prevScope && prevScope !== searchScope) ? react_jsx_runtime.jsx("span", {
1233
+ key: "old-" + prevScope,
1234
+ style: { position: "absolute", left: 0, top: 0, opacity: 0, animation: "dqt-fade-out 0.25s linear forwards" },
1235
+ children: prevScope === "title" ? "标题" : "全文"
1236
+ }) : null
1237
+ ]
1238
+ }),
1239
+ ",回车定位…"
1240
+ ]
1241
+ }) : null,
1242
+ ]
1066
1243
  }),
1067
1244
  searching ? react_jsx_runtime.jsx("span", {
1068
1245
  style: { width: 13, height: 13, flex: "none", display: "flex", alignItems: "center", justifyContent: "center" },
@@ -1083,6 +1260,43 @@ window.__ModuleLoader__.load({
1083
1260
  }) : react_jsx_runtime.jsx("span", {
1084
1261
  style: { fontSize: 11, color: C.muted, flex: "none", minWidth: 30, textAlign: "center" },
1085
1262
  children: matches.length > 0 ? ((matchIdx % matches.length) + 1) + "/" + matches.length : ""
1263
+ }),
1264
+ // scope toggle: 标题 <-> 全文 (round pill; background fades,
1265
+ // label text cross-fades old->new)
1266
+ react_jsx_runtime.jsx("button", {
1267
+ onClick: toggleScope,
1268
+ title: searchScope === "title" ? "当前:仅搜索标题。点击切换为全文搜索" : "当前:全文搜索。点击切换为仅标题",
1269
+ style: {
1270
+ flex: "none",
1271
+ width: 34,
1272
+ height: 34,
1273
+ display: "flex",
1274
+ alignItems: "center",
1275
+ justifyContent: "center",
1276
+ borderRadius: "50%",
1277
+ fontSize: 11,
1278
+ color: searchScope === "full" ? "var(--dsw-alias-brand-primary, #4f8cff)" : C.muted,
1279
+ background: searchScope === "full" ? "rgba(79,140,255,0.18)" : "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.14))",
1280
+ border: "none",
1281
+ cursor: "pointer",
1282
+ whiteSpace: "nowrap",
1283
+ transition: "background 0.25s ease"
1284
+ },
1285
+ children: react_jsx_runtime.jsx("span", {
1286
+ style: { position: "relative", display: "inline-flex", alignItems: "center", justifyContent: "center" },
1287
+ children: [
1288
+ react_jsx_runtime.jsx("span", {
1289
+ key: searchScope,
1290
+ style: { animation: "dqt-fade-in 0.25s linear", display: "block" },
1291
+ children: searchScope === "title" ? "标题" : "全文"
1292
+ }),
1293
+ (prevScope && prevScope !== searchScope) ? react_jsx_runtime.jsx("span", {
1294
+ key: "old-" + prevScope,
1295
+ style: { position: "absolute", opacity: 0, animation: "dqt-fade-out 0.25s linear forwards", display: "block" },
1296
+ children: prevScope === "title" ? "标题" : "全文"
1297
+ }) : null
1298
+ ]
1299
+ })
1086
1300
  })
1087
1301
  ]
1088
1302
  })
@@ -1373,7 +1587,7 @@ window.__ModuleLoader__.load({
1373
1587
  if (document.getElementById("dsh-quick-toc-css")) return;
1374
1588
  var s = document.createElement("style");
1375
1589
  s.id = "dsh-quick-toc-css";
1376
- s.textContent = "@keyframes dqt-spin{to{transform:rotate(360deg)}}";
1590
+ 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}}";
1377
1591
  document.head.appendChild(s);
1378
1592
  })();
1379
1593
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-quick-toc",
3
- "version": "0.2.0-beta",
3
+ "version": "0.2.2",
4
4
  "description": "Quick conversation TOC for DeepSeek Harness: markdown heading outline grouped by turn, with auto-follow highlight and smooth jump navigation",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",