dsh-quick-toc 0.7.14 → 0.8.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 CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  All notable changes to **dsh-quick-toc** are documented here. Chinese version: [CHANGELOG.md](CHANGELOG.md).
4
4
 
5
+ ## [0.8.1] - 2026-09-29
6
+
7
+ ### Fixed
8
+ - **The pin on the hover preview could not be clicked**: moving from an outline row towards the card started the fade first, so the card was gone before the pin was reached. Leaving a row now only arms the close, and reaching the card cancels it.
9
+
10
+ ### Changed
11
+ - **The collapsed handle took room from the text in a narrow window**: below a 760-pixel conversation column the collapsed side handle slides back into the divider; the droplet above stays as the way into the outline, and the side handle returns when the window widens.
12
+
13
+ ## [0.8.0] - 2026-09-29
14
+
15
+ ### Changed
16
+ - **The hover preview can be pinned**: a pin button on the card keeps it on screen after the pointer moves away, and its text can be selected; click the pin again, press Escape or click elsewhere to drop it. One pinned card at a time, and it stays where it was put.
17
+ - **Two previews side by side**: while a card is pinned, hovering another row opens a second card next to it — on whichever side has more room — so two places can be compared.
18
+ - **A context menu on outline rows**: right-click a heading row for "Pin this preview / Unpin" and "Jump to the end of this section".
19
+ - **Cards and menus animate in and out**: the pinned card, the hover preview and the row menu all fade in and out instead of appearing or vanishing instantly.
20
+
5
21
  ## [0.7.14] - 2026-09-28
6
22
 
7
23
  ### Changed
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  **dsh-quick-toc** 的重要变更都记录在这里。英文版见 [CHANGELOG.en.md](CHANGELOG.en.md)。
4
4
 
5
+ ## [0.8.1] - 2026-09-29
6
+
7
+ ### 修复
8
+ - **悬停预览卡上的图钉无法点击**:鼠标从大纲行移向卡片时会先开始淡出,卡片在图钉被点到之前就已经消失。现在离开行只是预备关闭,指针落到卡片上即取消。
9
+
10
+ ### 变更
11
+ - **窄窗口下折叠把手占用正文**:对话区域宽度不足 760 像素时,折叠态的侧边把手滑回分隔线内;顶部的水滴把手保留,作为打开大纲的入口,窗口变宽后侧边把手自动回来。
12
+
13
+ ## [0.8.0] - 2026-09-29
14
+
15
+ ### 变更
16
+ - **悬停预览可以钉住**:预览卡右上角多了一个图钉,点一下把它钉在屏幕上——鼠标移开也不消失,文字可以选中;再点一次图钉、按 Esc 或点别处取消。同时只保留一张钉住的卡,钉住的位置不会再动。
17
+ - **钉住后可以两处对照**:钉住状态下悬停别的行会同时显示第二张卡,两张不重叠——第二张放在空得多的一侧。
18
+ - **大纲行右键菜单**:在标题行上点右键可以「钉住预览/取消钉住」或「跳到本节末尾」。
19
+ - **卡片与菜单的收放都有动画**:钉住的卡、悬停预览和右键菜单都以淡入淡出出现和消失,不再瞬间弹出或消失。
20
+
5
21
  ## [0.7.14] - 2026-09-28
6
22
 
7
23
  ### 变更
package/README.en.md CHANGED
@@ -42,7 +42,8 @@ A conversation TOC plugin for [DeepSeek Harness](https://github.com/deepseek-ai/
42
42
 
43
43
  | Plugin | Supported DSH |
44
44
  | --- | --- |
45
- | **0.7.x** (latest, 0.7.13) | 0.1.5-rc.3, 0.1.7-alpha.1, 0.1.7-alpha.2, 0.1.7-rc.1, 0.1.7-rc.2, 0.2.0-rc.1 |
45
+ | **0.8.x** (latest, 0.8.1) | 0.1.5-rc.3, 0.1.7-alpha.1, 0.1.7-alpha.2, 0.1.7-rc.1, 0.1.7-rc.2, 0.2.0-rc.1 |
46
+ | 0.7.x (0.7.14) | 0.1.5-rc.3, 0.1.7-alpha.1, 0.1.7-alpha.2, 0.1.7-rc.1, 0.1.7-rc.2, 0.2.0-rc.1 |
46
47
  | 0.6.x (0.6.3) | 0.1.5-rc.1, 0.1.5-rc.2 |
47
48
  | 0.5.x (0.5.1) | 0.1.5-rc.1, 0.1.5-rc.2 |
48
49
  | 0.4.x (0.4.1) | 0.1.5-rc.1, 0.1.5-rc.2 |
package/README.md CHANGED
@@ -42,7 +42,8 @@
42
42
 
43
43
  | 插件版本 | 支持的 DSH 版本 |
44
44
  | --- | --- |
45
- | **0.7.x**(最新,0.7.13) | 0.1.5-rc.3、0.1.7-alpha.1、0.1.7-alpha.2、0.1.7-rc.1、0.1.7-rc.2、0.2.0-rc.1 |
45
+ | **0.8.x**(最新,0.8.1) | 0.1.5-rc.3、0.1.7-alpha.1、0.1.7-alpha.2、0.1.7-rc.1、0.1.7-rc.2、0.2.0-rc.1 |
46
+ | 0.7.x(0.7.14) | 0.1.5-rc.3、0.1.7-alpha.1、0.1.7-alpha.2、0.1.7-rc.1、0.1.7-rc.2、0.2.0-rc.1 |
46
47
  | 0.6.x(0.6.3) | 0.1.5-rc.1、0.1.5-rc.2 |
47
48
  | 0.5.x(0.5.1) | 0.1.5-rc.1、0.1.5-rc.2 |
48
49
  | 0.4.x(0.4.1) | 0.1.5-rc.1、0.1.5-rc.2 |
package/lib/client.js CHANGED
@@ -44,6 +44,11 @@ window.__ModuleLoader__.load({
44
44
  var Z_PANEL = 14; // the docked panel
45
45
  var Z_SHEET = 16; // the curtain, which replaces the docked panel while it is up
46
46
  var Z_HOVER = 18; // hover preview card: above both, still inside the free band
47
+ // A narrow conversation column has no room for a handle floating over the text: below
48
+ // NARROW_CONV_W the collapsed-panel handle slides back into the divider (its usual
49
+ // motion) while the droplet above the conversation stays as the way in. Nothing opens
50
+ // by itself — a resize never overrides the reader's own choice of surface.
51
+ var NARROW_CONV_W = 760;
47
52
  var Z_ERROR = 32; // crash banner: above our layers and the app's chrome, below
48
53
  // DSH's popovers (100) and modals (1000)
49
54
  var EASE = "cubic-bezier(0.22, 0.9, 0.3, 1)"; // smooth non-linear slide
@@ -148,6 +153,11 @@ window.__ModuleLoader__.load({
148
153
  "time.yesterday": "昨天",
149
154
  "time.beforeYesterday": "前天",
150
155
  "preview.truncated": "预览由宿主提供,可能被截断",
156
+ "preview.pin": "钉住预览",
157
+ "preview.unpin": "取消钉住",
158
+ "menu.pin": "钉住预览",
159
+ "menu.unpin": "取消钉住",
160
+ "menu.end": "跳到本节末尾",
151
161
  "resize.w": "拖拽调整宽度",
152
162
  "resize.h": "拖拽调整高度",
153
163
  "resize.wh": "拖拽同时调整宽高",
@@ -246,6 +256,11 @@ window.__ModuleLoader__.load({
246
256
  // carries the numeric date. Yesterday itself reads as the word in both.
247
257
  "time.yesterday": "yesterday",
248
258
  "preview.truncated": "Preview comes from the host and may be truncated",
259
+ "preview.pin": "Pin this preview",
260
+ "preview.unpin": "Unpin",
261
+ "menu.pin": "Pin this preview",
262
+ "menu.unpin": "Unpin this preview",
263
+ "menu.end": "Jump to the end of this section",
249
264
  "resize.w": "Drag to resize the width",
250
265
  "resize.h": "Drag to resize the height",
251
266
  "resize.wh": "Drag to resize width and height",
@@ -1564,6 +1579,10 @@ window.__ModuleLoader__.load({
1564
1579
  var _s2 = react.useState(null);
1565
1580
  var viewport = _s2[0];
1566
1581
  var setViewport = _s2[1];
1582
+ // how much room the conversation column itself has (the thing a docked panel
1583
+ // squeezes); 0 while it has not been measured yet, which never counts as narrow
1584
+ var convWidth = viewport ? Math.max(0, window.innerWidth - viewport.left - viewport.right) : 0;
1585
+ var narrowWindow = convWidth > 0 && convWidth < NARROW_CONV_W;
1567
1586
  react.useEffect(function () {
1568
1587
  // Self-healing, because the layout AROUND the conversation can change without
1569
1588
  // a resize: the host's own controls move that column about (measured in a real
@@ -1808,7 +1827,7 @@ window.__ModuleLoader__.load({
1808
1827
  var scrollToBottom = function (e) {
1809
1828
  if (e && e.stopPropagation) e.stopPropagation();
1810
1829
  outlineTouchRef.current = Date.now();
1811
- hoverEnd();
1830
+ hoverEnd(true);
1812
1831
  var el = listRef.current;
1813
1832
  if (!el) return;
1814
1833
  // optimistic: the list is on its way to the newest row, so the button starts
@@ -1894,7 +1913,7 @@ window.__ModuleLoader__.load({
1894
1913
  var backToBrowse = function (e) {
1895
1914
  if (e && e.stopPropagation) e.stopPropagation();
1896
1915
  outlineTouchRef.current = Date.now();
1897
- hoverEnd();
1916
+ hoverEnd(true);
1898
1917
  var el = listRef.current;
1899
1918
  var tgt = liveTgt();
1900
1919
  if (!el || !tgt) return;
@@ -1960,6 +1979,17 @@ window.__ModuleLoader__.load({
1960
1979
  var setHoverClosing = _sHovOut[1];
1961
1980
  var hoverTimerRef = react.useRef(null);
1962
1981
  var hoverCloseTimerRef = react.useRef(null);
1982
+ // The card carries the pin button, so the pointer has to be able to travel from the
1983
+ // row onto the card without the card fading on the way. Leaving a row therefore only
1984
+ // ARMS the close; the card's own mouseenter disarms it, and the fade starts after a
1985
+ // grace period that a crossing pointer fits into.
1986
+ var HOVER_GRACE_MS = 260;
1987
+ var hoverGraceTimerRef = react.useRef(null);
1988
+ var disarmHoverClose = function () {
1989
+ if (hoverGraceTimerRef.current) { clearTimeout(hoverGraceTimerRef.current); hoverGraceTimerRef.current = null; }
1990
+ if (hoverCloseTimerRef.current) { clearTimeout(hoverCloseTimerRef.current); hoverCloseTimerRef.current = null; }
1991
+ setHoverClosing(false);
1992
+ };
1963
1993
  var hoverStart = function (info, el) {
1964
1994
  // the hover card is a preference: off means it never opens (the outline rows
1965
1995
  // themselves keep their hover tint)
@@ -1968,11 +1998,7 @@ window.__ModuleLoader__.load({
1968
1998
  hoverTimerRef.current = setTimeout(function () {
1969
1999
  var r = el && el.getBoundingClientRect ? el.getBoundingClientRect() : null;
1970
2000
  if (!r || r.width <= 0) return;
1971
- if (hoverCloseTimerRef.current) {
1972
- clearTimeout(hoverCloseTimerRef.current);
1973
- hoverCloseTimerRef.current = null;
1974
- }
1975
- setHoverClosing(false);
2001
+ disarmHoverClose();
1976
2002
  setHoverCard({
1977
2003
  title: info.title,
1978
2004
  sub: info.sub,
@@ -1981,21 +2007,177 @@ window.__ModuleLoader__.load({
1981
2007
  ghost: info.ghost,
1982
2008
  top: r.top,
1983
2009
  left: r.left,
1984
- right: r.right
2010
+ right: r.right,
2011
+ // the row this card describes: pinning keeps it, and the menu labels itself from it
2012
+ key: info.key,
2013
+ idx: info.idx
1985
2014
  });
1986
2015
  }, 260);
1987
2016
  };
1988
- // leaving fades the card out instead of dropping it on the next frame
1989
- var hoverEnd = function () {
2017
+ // leaving fades the card out instead of dropping it on the next frame — but only
2018
+ // after a grace period, because the card carries the pin and the pointer has to be
2019
+ // able to travel from the row onto it (an immediate fade made the pin unreachable).
2020
+ var hoverEnd = function (immediate) {
1990
2021
  if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
2022
+ if (hoverGraceTimerRef.current) clearTimeout(hoverGraceTimerRef.current);
1991
2023
  if (hoverCloseTimerRef.current) clearTimeout(hoverCloseTimerRef.current);
1992
- setHoverClosing(true);
1993
- hoverCloseTimerRef.current = setTimeout(function () {
1994
- hoverCloseTimerRef.current = null;
1995
- setHoverCard(null);
1996
- setHoverClosing(false);
1997
- }, 240);
2024
+ var grace = immediate ? 0 : HOVER_GRACE_MS;
2025
+ hoverGraceTimerRef.current = setTimeout(function () {
2026
+ hoverGraceTimerRef.current = null;
2027
+ setHoverClosing(true);
2028
+ hoverCloseTimerRef.current = setTimeout(function () {
2029
+ hoverCloseTimerRef.current = null;
2030
+ setHoverCard(null);
2031
+ setHoverClosing(false);
2032
+ }, 240);
2033
+ }, grace);
2034
+ };
2035
+
2036
+ // ---- pinned preview + the row menu (0.8.0) --------------------------------
2037
+ // Pinning freezes one card where it is, so the pointer can move on and a second
2038
+ // row can be hovered next to it: two previews read side by side. One pin at a
2039
+ // time — pinning another row replaces it (the newest pin is the one that stays).
2040
+ var _sPin = react.useState(null);
2041
+ var pinnedCard = _sPin[0];
2042
+ var setPinnedCard = _sPin[1];
2043
+ var pinFromCard = function (card, opts) {
2044
+ if (!card) return;
2045
+ if (pinCloseTimerRef.current) { clearTimeout(pinCloseTimerRef.current); pinCloseTimerRef.current = null; }
2046
+ setPinnedClosing(false);
2047
+ setPinnedCard({
2048
+ title: card.title,
2049
+ sub: card.sub,
2050
+ preview: card.preview,
2051
+ meta: card.meta,
2052
+ ghost: card.ghost,
2053
+ key: card.key,
2054
+ top: card.top,
2055
+ left: card.left,
2056
+ right: card.right,
2057
+ // adopting the card that is already on screen must not re-play its entrance
2058
+ // animation, or pinning would blink the very card the reader is looking at
2059
+ fresh: !!(opts && opts.fresh)
2060
+ });
2061
+ // the card the reader is looking at BECOMES the pinned one: the hover card is
2062
+ // dropped in the same frame, so pinning never moves what is on screen
2063
+ if (hoverCloseTimerRef.current) { clearTimeout(hoverCloseTimerRef.current); hoverCloseTimerRef.current = null; }
2064
+ setHoverClosing(false);
2065
+ setHoverCard(null);
1998
2066
  };
2067
+ // The pinned card STAYS where it was put: scrolling the outline leaves it alone.
2068
+ // Dropping it runs the same fade the hover card uses, so nothing ever pops out.
2069
+ var _sPinClose = react.useState(false);
2070
+ var pinnedClosing = _sPinClose[0];
2071
+ var setPinnedClosing = _sPinClose[1];
2072
+ var pinCloseTimerRef = react.useRef(null);
2073
+ var unpinCard = function () {
2074
+ if (!pinnedCard || pinCloseTimerRef.current) return;
2075
+ setPinnedClosing(true);
2076
+ pinCloseTimerRef.current = setTimeout(function () {
2077
+ pinCloseTimerRef.current = null;
2078
+ setPinnedCard(null);
2079
+ setPinnedClosing(false);
2080
+ }, 180);
2081
+ };
2082
+ // the pinned card measures itself so the second card can be placed against it
2083
+ var pinnedRef = react.useRef(null);
2084
+ // the card keeps the row it describes, so the menu can say pin/unpin for it
2085
+ var isPinned = function (key) { return !!pinnedCard && !!key && pinnedCard.key === key; };
2086
+
2087
+ // A right-click on an outline row opens a small menu instead of the browser's:
2088
+ // pin/unpin this row's preview and jump to the end of its section.
2089
+ var _sMenu = react.useState(null);
2090
+ var rowMenu = _sMenu[0];
2091
+ var setRowMenu = _sMenu[1];
2092
+ // the menu closes through a short fade like every other card
2093
+ var _sMenuClose = react.useState(false);
2094
+ var menuClosing = _sMenuClose[0];
2095
+ var setMenuClosing = _sMenuClose[1];
2096
+ var menuCloseTimerRef = react.useRef(null);
2097
+ var openRowMenu = function (e, info, key, idx) {
2098
+ if (e && e.preventDefault) e.preventDefault();
2099
+ if (e && e.stopPropagation) e.stopPropagation();
2100
+ var x = e && typeof e.clientX === "number" ? e.clientX : 0;
2101
+ var y = e && typeof e.clientY === "number" ? e.clientY : 0;
2102
+ if (menuCloseTimerRef.current) { clearTimeout(menuCloseTimerRef.current); menuCloseTimerRef.current = null; }
2103
+ setMenuClosing(false);
2104
+ // pinning from the menu puts the card where the row is, not under the pointer
2105
+ var rect = e && e.currentTarget && e.currentTarget.getBoundingClientRect ? e.currentTarget.getBoundingClientRect() : null;
2106
+ setRowMenu({
2107
+ x: x,
2108
+ y: y,
2109
+ info: info,
2110
+ key: key,
2111
+ idx: idx,
2112
+ rowTop: rect ? rect.top : undefined,
2113
+ rowLeft: rect ? rect.left : undefined,
2114
+ rowRight: rect ? rect.right : undefined
2115
+ });
2116
+ };
2117
+ var closeRowMenu = function () {
2118
+ if (!rowMenu || menuCloseTimerRef.current) return;
2119
+ setMenuClosing(true);
2120
+ menuCloseTimerRef.current = setTimeout(function () {
2121
+ menuCloseTimerRef.current = null;
2122
+ setRowMenu(null);
2123
+ setMenuClosing(false);
2124
+ }, 150);
2125
+ };
2126
+ // Esc / a click anywhere else closes the menu and drops the pin; a pinned card
2127
+ // also goes away when the panel closes or the surface switches (below).
2128
+ // The listeners are bound ONCE and read the live state through a ref: when they were
2129
+ // re-bound on every state change the registered closure ran one step behind (Esc
2130
+ // closed the menu a second time and never reached the pin).
2131
+ var latestPin = react.useRef({});
2132
+ latestPin.current = {
2133
+ pinned: !!pinnedCard,
2134
+ menu: !!rowMenu,
2135
+ unpin: unpinCard,
2136
+ closeMenu: closeRowMenu
2137
+ };
2138
+ react.useEffect(function () {
2139
+ var onDown = function (ev) {
2140
+ var L = latestPin.current;
2141
+ if (!L.pinned && !L.menu) return;
2142
+ // a right-click is how the row menu opens: it must not drop the pin the reader
2143
+ // is comparing against
2144
+ if (ev.button === 2) return;
2145
+ var t = ev.target;
2146
+ if (t && t.closest && (t.closest("[data-dqt-pin]") || t.closest("[data-dqt-menu]"))) return;
2147
+ L.closeMenu();
2148
+ if (L.pinned && t && t.closest && t.closest("[data-dqt-pinned]")) return;
2149
+ L.unpin();
2150
+ };
2151
+ var onKey = function (ev) {
2152
+ var L = latestPin.current;
2153
+ if (ev.key !== "Escape") return;
2154
+ // Esc takes the menu first, then the pin; only when neither is up does it
2155
+ // reach the panel's own handler and close the panel.
2156
+ if (L.menu) { L.closeMenu(); ev.stopPropagation(); ev.preventDefault(); return; }
2157
+ if (L.pinned) { L.unpin(); ev.stopPropagation(); ev.preventDefault(); }
2158
+ };
2159
+ document.addEventListener("mousedown", onDown, true);
2160
+ document.addEventListener("keydown", onKey, true);
2161
+ return function () {
2162
+ document.removeEventListener("mousedown", onDown, true);
2163
+ document.removeEventListener("keydown", onKey, true);
2164
+ };
2165
+ }, []);
2166
+ // a pinned card belongs to the surface it was pinned on: closing the panel or
2167
+ // switching between the panel and the curtain drops it (through the same fade).
2168
+ // These act on the TRANSITION only — a plain re-run must never touch a live pin.
2169
+ var wasOpenRef = react.useRef(open);
2170
+ react.useEffect(function () {
2171
+ if (wasOpenRef.current === open) return;
2172
+ wasOpenRef.current = open;
2173
+ if (!open) unpinCard();
2174
+ }, [open]);
2175
+ var wasSheetRef = react.useRef(sheetOpen);
2176
+ react.useEffect(function () {
2177
+ if (wasSheetRef.current === sheetOpen) return;
2178
+ wasSheetRef.current = sheetOpen;
2179
+ unpinCard();
2180
+ }, [sheetOpen]);
1999
2181
 
2000
2182
  // ---- the outline belongs to the CHAT view only: when the center column
2001
2183
  // switches to another view (trajectory / context / plugin views), fade the
@@ -2969,7 +3151,7 @@ window.__ModuleLoader__.load({
2969
3151
  autoGestureRef.current = Date.now(); // a wheel tick is a reader gesture
2970
3152
  var el = listRef.current;
2971
3153
  if (!el) return;
2972
- hoverEnd();
3154
+ hoverEnd(true);
2973
3155
  if (e.deltaY >= 0) {
2974
3156
  // still scrolling down at the very end of the outline: say so. Outline
2975
3157
  // mode only — the search list has its own persistent hint line.
@@ -3006,7 +3188,7 @@ window.__ModuleLoader__.load({
3006
3188
  var upward = el.scrollTop < lastScrollTopRef.current - 2;
3007
3189
  if (upward) {
3008
3190
  if (hint === "more") setHint("");
3009
- if (hoverCard) hoverEnd();
3191
+ if (hoverCard) hoverEnd(true);
3010
3192
  }
3011
3193
  lastScrollTopRef.current = el.scrollTop;
3012
3194
  syncBackBtn(el); // reached the remembered place by hand? the button is done
@@ -4747,6 +4929,8 @@ window.__ModuleLoader__.load({
4747
4929
  onOpenTurn: openTurn,
4748
4930
  hoverStart: hoverStart,
4749
4931
  hoverEnd: hoverEnd,
4932
+ // right-click on a row: pin its preview / jump to the end of its section
4933
+ rowMenu: openRowMenu,
4750
4934
  // the curtain is wide: rows switch to a one-line, multi-part layout that
4751
4935
  // puts the section's opening text out to the right instead of under the
4752
4936
  // title (see renderItem / renderResultRow)
@@ -6009,7 +6193,10 @@ window.__ModuleLoader__.load({
6009
6193
  // vanishing ON the divider line. `open` also drives it (fade + slide home) and
6010
6194
  // `edgeGone` keeps the element in the tree until that motion has played.
6011
6195
  var EDGE_W = 26;
6012
- var edgeShown = !open && handleShown && chatViewActive;
6196
+ // a narrow conversation column leaves no room for a handle floating over the text:
6197
+ // below the threshold it slides back into the divider (its usual motion), while the
6198
+ // droplet above the conversation stays put as the way in
6199
+ var edgeShown = !open && handleShown && chatViewActive && !narrowWindow;
6013
6200
  var edgeLineLeft = viewport ? viewport.left : 0;
6014
6201
  var edgeLineRight = viewport ? viewport.right + 52 : 60; // clear of the milestone rail
6015
6202
  var edgeEl = (!open || !edgeGone) ? react_jsx_runtime.jsx("div", {
@@ -6197,42 +6384,110 @@ window.__ModuleLoader__.load({
6197
6384
  // Rendered INSIDE the body portal (position: fixed) so the outline's own
6198
6385
  // scroll container can never clip it, and pointer-events: none so it can
6199
6386
  // never steal the hover that opened it.
6200
- var hoverEl = null;
6201
- if (hoverCard && open) {
6387
+ // One card body, used for both the hover card and the pinned one: same width,
6388
+ // same fields, same layering. `opts.pinned` turns it into the frozen copy (it
6389
+ // takes pointer events, so its text can be selected and its pin can be clicked);
6390
+ // the hover card stays click-through except for its pin button.
6391
+ var renderPreviewCard = function (card, opts) {
6202
6392
  var cardW = 264;
6203
- var cardTop = Math.max(8, Math.min(hoverCard.top - 4, window.innerHeight - 210));
6204
- var cardLeft = dockRight
6205
- ? Math.max(8, hoverCard.left - cardW - 10)
6206
- : Math.min(window.innerWidth - cardW - 8, hoverCard.right + 10);
6207
- var cardBody = hoverCard.preview || hoverCard.sub || "";
6208
- var cardChildren = [
6393
+ var top = Math.max(8, Math.min(card.top - 4, window.innerHeight - 210));
6394
+ var left = dockRight
6395
+ ? Math.max(8, card.left - cardW - 10)
6396
+ : Math.min(window.innerWidth - cardW - 8, card.right + 10);
6397
+ // With a card already pinned, a second one on the same band would cover it: put
6398
+ // the hover card on whichever side of the pinned card has more room (measured,
6399
+ // so a short pinned card does not push the second one further than needed).
6400
+ if (!opts.pinned && pinnedCard && pinnedVisible && !pinnedClosing) {
6401
+ var pinRect = pinnedRef.current && pinnedRef.current.getBoundingClientRect ? pinnedRef.current.getBoundingClientRect() : null;
6402
+ var pinTop = pinRect && pinRect.height > 0 ? pinRect.top : Math.max(8, Math.min(pinnedCard.top - 4, window.innerHeight - 210));
6403
+ var pinH = pinRect && pinRect.height > 0 ? pinRect.height : 210;
6404
+ var pinLeft = pinRect && pinRect.width > 0
6405
+ ? pinRect.left
6406
+ : (dockRight ? Math.max(8, pinnedCard.left - cardW - 10) : Math.min(window.innerWidth - cardW - 8, pinnedCard.right + 10));
6407
+ var overlaps = Math.abs(pinLeft - left) < cardW && top < pinTop + pinH + 6 && pinTop < top + 210 + 6;
6408
+ if (overlaps) {
6409
+ var belowRoom = window.innerHeight - 8 - (pinTop + pinH + 8);
6410
+ var aboveRoom = pinTop - 8 - 8;
6411
+ top = belowRoom >= aboveRoom
6412
+ ? Math.max(8, Math.min(pinTop + pinH + 8, window.innerHeight - 210 - 8))
6413
+ : Math.max(8, pinTop - 210 - 8);
6414
+ }
6415
+ }
6416
+ var body = card.preview || card.sub || "";
6417
+ var kids = [
6209
6418
  react_jsx_runtime.jsx("div", {
6210
- style: { fontSize: 12, fontWeight: 600, color: C.text, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
6211
- children: hoverCard.title
6212
- }, "hc-t")
6419
+ style: { display: "flex", alignItems: "flex-start", gap: 6 },
6420
+ children: [
6421
+ react_jsx_runtime.jsx("div", {
6422
+ style: { flex: "1 1 auto", minWidth: 0, fontSize: 12, fontWeight: 600, color: C.text, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
6423
+ children: card.title
6424
+ }, "hc-t"),
6425
+ react_jsx_runtime.jsx("button", {
6426
+ "data-dqt-pin": opts.pinned ? "on" : "off",
6427
+ title: opts.pinned ? T("preview.unpin") : T("preview.pin"),
6428
+ onClick: function (e) {
6429
+ e.preventDefault();
6430
+ e.stopPropagation();
6431
+ if (opts.pinned) unpinCard();
6432
+ else pinFromCard(card);
6433
+ },
6434
+ style: {
6435
+ flex: "none",
6436
+ width: 20,
6437
+ height: 20,
6438
+ marginTop: -2,
6439
+ marginRight: -4,
6440
+ display: "flex",
6441
+ alignItems: "center",
6442
+ justifyContent: "center",
6443
+ border: "none",
6444
+ borderRadius: 6,
6445
+ background: opts.pinned ? C.hover : "transparent",
6446
+ color: opts.pinned ? C.text : C.muted,
6447
+ cursor: "pointer",
6448
+ // the hover card is click-through except for this button, so reaching
6449
+ // for it does not depend on the row staying hovered
6450
+ pointerEvents: "auto",
6451
+ padding: 0,
6452
+ fontSize: 11,
6453
+ lineHeight: "20px"
6454
+ },
6455
+ children: opts.pinned ? "\u25c9" : "\u25cb"
6456
+ }, "hc-pin")
6457
+ ]
6458
+ }, "hc-head")
6213
6459
  ];
6214
- if (hoverCard.meta) {
6215
- cardChildren.push(react_jsx_runtime.jsx("div", {
6460
+ if (card.meta) {
6461
+ kids.push(react_jsx_runtime.jsx("div", {
6216
6462
  style: { fontSize: 10, color: C.muted, opacity: 0.8, marginTop: 2, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
6217
- children: hoverCard.meta
6463
+ children: card.meta
6218
6464
  }, "hc-m"));
6219
6465
  }
6220
- cardChildren.push(react_jsx_runtime.jsx("div", {
6466
+ kids.push(react_jsx_runtime.jsx("div", {
6221
6467
  style: { fontSize: 11, lineHeight: "16px", color: C.text, opacity: 0.9, marginTop: 4, maxHeight: 116, overflow: "hidden" },
6222
- children: cardBody || (hoverCard.ghost ? T("turn.loadTip") : "")
6468
+ children: body || (card.ghost ? T("turn.loadTip") : "")
6223
6469
  }, "hc-b"));
6224
- if (hoverCard.ghost) {
6225
- cardChildren.push(react_jsx_runtime.jsx("div", {
6470
+ if (card.ghost) {
6471
+ kids.push(react_jsx_runtime.jsx("div", {
6226
6472
  style: { fontSize: 10, color: C.muted, opacity: 0.7, marginTop: 4 },
6227
6473
  children: T("preview.truncated")
6228
6474
  }, "hc-n"));
6229
6475
  }
6230
- hoverEl = react_jsx_runtime.jsx("div", {
6231
- className: "dqt-hover" + (hoverClosing ? " dqt-hover-closing" : ""),
6476
+ return react_jsx_runtime.jsx("div", {
6477
+ className: "dqt-hover" + (opts.closing ? " dqt-hover-closing" : "") + (opts.pinned ? " dqt-hover-pinned" : "")
6478
+ // a card adopted from the hover card is already on screen: skip its entrance
6479
+ + (opts.pinned && !card.fresh ? " dqt-hover-static" : ""),
6480
+ "data-dqt-pinned": opts.pinned ? "on" : undefined,
6481
+ ref: opts.pinned ? pinnedRef : undefined,
6482
+ onMouseEnter: opts.pinned ? undefined : function () {
6483
+ // the pointer reached the card: keep it open so its pin can be clicked
6484
+ disarmHoverClose();
6485
+ },
6486
+ onMouseLeave: opts.pinned ? undefined : function () { hoverEnd(); },
6232
6487
  style: {
6233
6488
  position: "fixed",
6234
- top: cardTop,
6235
- left: cardLeft,
6489
+ top: top,
6490
+ left: left,
6236
6491
  width: cardW,
6237
6492
  padding: "8px 10px",
6238
6493
  borderRadius: 8,
@@ -6243,11 +6498,107 @@ window.__ModuleLoader__.load({
6243
6498
  boxShadow: "0 8px 24px rgba(0,0,0,0.28)",
6244
6499
  // above the panel and the curtain — it is a sibling of both in this
6245
6500
  // portal, and both sit below it in the layer band we claim
6246
- zIndex: Z_HOVER,
6247
- pointerEvents: "none"
6501
+ zIndex: Z_HOVER + (opts.pinned ? 1 : 0),
6502
+ // a fading card is inert: it must not swallow the click that dismissed it.
6503
+ // While it is up the card IS clickable — it carries the pin, and the pointer
6504
+ // has to be able to get there from the row.
6505
+ pointerEvents: opts.closing ? "none" : "auto",
6506
+ userSelect: opts.pinned ? "text" : "none"
6507
+ },
6508
+ children: kids
6509
+ }, opts.pinned ? "pinned-card" : "hover-card");
6510
+ };
6511
+
6512
+ // The pinned card is FROZEN where it was put: scrolling the outline leaves it alone,
6513
+ // and it only leaves the screen through the same fade the hover card uses.
6514
+ var pinnedVisible = !!(pinnedCard && open);
6515
+ var pinnedEl = pinnedVisible
6516
+ ? renderPreviewCard(pinnedCard, { pinned: true, closing: pinnedClosing })
6517
+ : null;
6518
+ var hoverEl = null;
6519
+ if (hoverCard && open) hoverEl = renderPreviewCard(hoverCard, { pinned: false, closing: hoverClosing });
6520
+
6521
+ // ---- the row menu ---------------------------------------------------------
6522
+ // Small, fixed-position, keyboard-reachable: ↑/↓ move, Enter runs, Esc closes.
6523
+ var rowMenuEl = null;
6524
+ if (rowMenu && open) {
6525
+ var menuW = 208;
6526
+ var menuH = 76;
6527
+ var menuLeft = Math.max(8, Math.min(rowMenu.x, window.innerWidth - menuW - 8));
6528
+ var menuTop = Math.max(8, Math.min(rowMenu.y, window.innerHeight - menuH - 8));
6529
+ var menuItems = [
6530
+ {
6531
+ id: "pin",
6532
+ label: isPinned(rowMenu.key) ? T("menu.unpin") : T("menu.pin"),
6533
+ run: function () {
6534
+ if (isPinned(rowMenu.key)) unpinCard();
6535
+ else pinFromCard({
6536
+ title: rowMenu.info.title,
6537
+ sub: rowMenu.info.sub,
6538
+ preview: rowMenu.info.preview,
6539
+ meta: rowMenu.info.meta,
6540
+ ghost: rowMenu.info.ghost,
6541
+ key: rowMenu.key,
6542
+ // pin it where its ROW is, not where the right-click landed
6543
+ top: rowMenu.rowTop !== undefined ? rowMenu.rowTop : rowMenu.y,
6544
+ left: rowMenu.rowLeft !== undefined ? rowMenu.rowLeft : rowMenu.x,
6545
+ right: rowMenu.rowRight !== undefined ? rowMenu.rowRight : rowMenu.x
6546
+ }, { fresh: true });
6547
+ closeRowMenu();
6548
+ }
6549
+ },
6550
+ {
6551
+ id: "end",
6552
+ label: T("menu.end"),
6553
+ run: function () {
6554
+ var key = rowMenu.key;
6555
+ var idx = rowMenu.idx;
6556
+ closeRowMenu();
6557
+ if (typeof endJumpRow === "function") endJumpRow(key, idx === undefined ? 0 : idx);
6558
+ }
6559
+ }
6560
+ ];
6561
+ rowMenuEl = react_jsx_runtime.jsx("div", {
6562
+ "data-dqt-menu": "on",
6563
+ className: "dqt-rowmenu" + (menuClosing ? " dqt-rowmenu-closing" : ""),
6564
+ role: "menu",
6565
+ style: {
6566
+ position: "fixed",
6567
+ top: menuTop,
6568
+ left: menuLeft,
6569
+ width: menuW,
6570
+ padding: 4,
6571
+ borderRadius: 8,
6572
+ background: C.panelBg,
6573
+ color: C.text,
6574
+ border: "1px solid " + C.panelBorder,
6575
+ boxShadow: "0 10px 28px rgba(0,0,0,0.32)",
6576
+ zIndex: Z_HOVER + 2,
6577
+ // a fading menu must not swallow the click that dismissed it
6578
+ pointerEvents: menuClosing ? "none" : "auto"
6248
6579
  },
6249
- children: cardChildren
6250
- }, "hover-card");
6580
+ children: menuItems.map(function (item) {
6581
+ return react_jsx_runtime.jsx("button", {
6582
+ role: "menuitem",
6583
+ "data-dqt-menu-item": item.id,
6584
+ onClick: function (e) { e.preventDefault(); e.stopPropagation(); item.run(); },
6585
+ style: {
6586
+ display: "block",
6587
+ width: "100%",
6588
+ textAlign: "left",
6589
+ padding: "6px 8px",
6590
+ border: "none",
6591
+ borderRadius: 6,
6592
+ background: "transparent",
6593
+ color: C.text,
6594
+ cursor: "pointer",
6595
+ fontSize: 12,
6596
+ lineHeight: "18px"
6597
+ },
6598
+ children: item.label
6599
+ }, "menu-" + item.id);
6600
+ })
6601
+ }, "row-menu");
6251
6602
  }
6252
6603
 
6253
6604
  // ---- the curtain shell ---------------------------------------------------
@@ -6333,7 +6684,7 @@ window.__ModuleLoader__.load({
6333
6684
 
6334
6685
  return react_dom.createPortal(
6335
6686
  react_jsx_runtime.jsx(ErrorBoundary, {
6336
- children: [sheetShellEl, edgeEl, topHandleEl, hoverEl]
6687
+ children: [sheetShellEl, edgeEl, topHandleEl, pinnedEl, hoverEl, rowMenuEl]
6337
6688
  }),
6338
6689
  document.body
6339
6690
  );
@@ -6419,7 +6770,10 @@ window.__ModuleLoader__.load({
6419
6770
  sub: n.sub || "",
6420
6771
  preview: n.preview || "",
6421
6772
  meta: [n.time, path ? path.path : ""].filter(Boolean).join(" · "),
6422
- ghost: false
6773
+ ghost: false,
6774
+ // what pinning and the row menu need to act on this very row
6775
+ key: n.key,
6776
+ idx: n.idx
6423
6777
  };
6424
6778
  var rows;
6425
6779
  if (wide) {
@@ -6520,6 +6874,9 @@ window.__ModuleLoader__.load({
6520
6874
  if (b) b.style.opacity = "0";
6521
6875
  if (extra && extra.hoverEnd) extra.hoverEnd();
6522
6876
  },
6877
+ onContextMenu: function (e) {
6878
+ if (extra && extra.rowMenu) extra.rowMenu(e, hoverInfo, n.key, n.idx);
6879
+ },
6523
6880
  "data-jump-key": n.key,
6524
6881
  "data-jump-idx": n.idx !== undefined ? String(n.idx) : "0",
6525
6882
  // keyboard navigation walks the ORDER of these rows inside the list
@@ -7646,9 +8003,17 @@ window.__ModuleLoader__.load({
7646
8003
  // re-renders (which rewrite inline styles) cannot restart or cancel them
7647
8004
  ".dqt-levels-pop{animation:dqt-pop-in 0.22s cubic-bezier(0.22, 0.9, 0.3, 1) both;will-change:transform,opacity}" +
7648
8005
  // hover preview card: fades in place, and fades OUT when the pointer
7649
- // leaves (both as stylesheet classes so re-renders cannot cancel them)
8006
+ // leaves (both as stylesheet classes so re-renders cannot cancel them).
8007
+ // The pinned card wears the same two classes, so pinning, unpinning, Esc, a
8008
+ // click outside and a panel close all animate; `.dqt-hover-static` is for the
8009
+ // card that is ADOPTED from the hover card (it is already on screen, so it must
8010
+ // not re-play the entrance and blink).
7650
8011
  ".dqt-hover{animation:dqt-fade-in 0.12s linear both}" +
7651
- ".dqt-hover-closing{animation:dqt-fade-out 0.16s cubic-bezier(0.2, 0.9, 0.3, 1) both}" +
8012
+ ".dqt-hover-static{animation:none}" +
8013
+ ".dqt-hover-closing{animation:dqt-fade-out 0.16s cubic-bezier(0.2, 0.9, 0.3, 1) both;pointer-events:none}" +
8014
+ // the outline row menu: same vocabulary as the level picker (a small pop)
8015
+ ".dqt-rowmenu{animation:dqt-pop-in 0.14s cubic-bezier(0.22, 0.9, 0.3, 1) both;will-change:transform,opacity}" +
8016
+ ".dqt-rowmenu-closing{animation:dqt-pop-out 0.14s cubic-bezier(0.2, 0.9, 0.3, 1) both;pointer-events:none;will-change:transform,opacity}" +
7652
8017
  // transient edge banner over the bottom of the outline list
7653
8018
  "@keyframes dqt-toast-in{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:none}}" +
7654
8019
  "@keyframes dqt-toast-out{from{opacity:1;transform:none}to{opacity:0;transform:none}}" +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-quick-toc",
3
- "version": "0.7.14",
3
+ "version": "0.8.1",
4
4
  "description": "Conversation TOC for DeepSeek Harness (DSH): a whole-session, turn-grouped Markdown outline with hover previews, a level filter, a full-width curtain, jump-to-end buttons, title/full-text/cross-session search and a Chinese/English interface.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",