dsh-mega-chat-nav 0.1.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/LICENSE +201 -0
- package/README.en.md +149 -0
- package/README.md +149 -0
- package/cordis.patch.yml +4 -0
- package/lib/client/NavOverlay.js +35 -0
- package/lib/client/NavRail.js +12 -0
- package/lib/client/QuestionNavSettingsTab.js +26 -0
- package/lib/client/QuestionNavStrip.js +555 -0
- package/lib/client/components/ConfigPanel.js +14 -0
- package/lib/client/components/DotRail.js +62 -0
- package/lib/client/components/NavOverlay.js +121 -0
- package/lib/client/components/NavRail.js +97 -0
- package/lib/client/components/RailDispatch.js +22 -0
- package/lib/client/components/RailHost.js +79 -0
- package/lib/client/components/SearchBox.js +66 -0
- package/lib/client/components/SettingsPage.js +46 -0
- package/lib/client/components/StandaloneSettings.js +16 -0
- package/lib/client/components/codex/CodexRail.js +139 -0
- package/lib/client/components/deepseek/DeepseekRail.js +264 -0
- package/lib/client/components/minimal/MinimalRail.js +135 -0
- package/lib/client/components/minimal/cards.js +32 -0
- package/lib/client/components/minimal/dot.js +11 -0
- package/lib/client/components/minimal/hint.js +30 -0
- package/lib/client/components/minimal/paging.js +5 -0
- package/lib/client/components/minimal/quick-settings.js +28 -0
- package/lib/client/components/minimal/search.js +67 -0
- package/lib/client/components/minimal/useRail.js +716 -0
- package/lib/client/components/rail/cards.js +32 -0
- package/lib/client/components/rail/hint.js +30 -0
- package/lib/client/components/rail/paging.js +5 -0
- package/lib/client/components/rail/quick-settings.js +28 -0
- package/lib/client/components/rail/search.js +76 -0
- package/lib/client/components/rail/useRail.js +845 -0
- package/lib/client/components/settings/SettingsPage.js +46 -0
- package/lib/client/components/settings/SettingsTab.js +17 -0
- package/lib/client/components/settings/StandaloneSettings.js +16 -0
- package/lib/client/components/shared/Cascade.js +26 -0
- package/lib/client/components/shared/ConfigPanel.js +27 -0
- package/lib/client/components/shared/Dot.js +8 -0
- package/lib/client/components/shared/Hint.js +5 -0
- package/lib/client/components/shared/PagingButton.js +5 -0
- package/lib/client/components/shared/RailShell.js +78 -0
- package/lib/client/components/shared/SearchBox.js +63 -0
- package/lib/client/components/shared/hooks.js +427 -0
- package/lib/client/components/shared/types.js +1 -0
- package/lib/client/config.js +1 -0
- package/lib/client/engines/jump.js +34 -0
- package/lib/client/engines/useAtBottom.js +25 -0
- package/lib/client/engines/useJump.js +40 -0
- package/lib/client/engines/useScrollSpy.js +88 -0
- package/lib/client/hooks.js +11 -0
- package/lib/client/index.js +312 -0
- package/lib/client/jump.js +255 -0
- package/lib/client/locales.js +142 -0
- package/lib/client/pure.js +61 -0
- package/lib/client/settings.js +369 -0
- package/lib/client/virtual/VirtualList.js +21 -0
- package/lib/client.js +3213 -0
- package/lib/compat.js +145 -0
- package/lib/config.js +37 -0
- package/lib/core/active-dot.js +17 -0
- package/lib/core/align.js +9 -0
- package/lib/core/config.js +37 -0
- package/lib/core/focus.js +38 -0
- package/lib/core/jump.js +98 -0
- package/lib/core/logic/active-dot.js +16 -0
- package/lib/core/logic/focus.js +34 -0
- package/lib/core/logic/jump.js +188 -0
- package/lib/core/logic/nodes.js +59 -0
- package/lib/core/model/nav-item.js +1 -0
- package/lib/core/model/turns.js +73 -0
- package/lib/core/nav-item.js +1 -0
- package/lib/core/nodes.js +57 -0
- package/lib/core/page-size.js +7 -0
- package/lib/core/question-entry.js +1 -0
- package/lib/core/time.js +23 -0
- package/lib/core/turn-dots.js +85 -0
- package/lib/core/utils/measure.js +24 -0
- package/lib/core/utils/time.js +26 -0
- package/lib/index.js +64 -0
- package/lib/nav-item.js +1 -0
- package/lib/nodes.js +59 -0
- package/lib/projection.js +126 -0
- package/lib/schema.js +26 -0
- package/lib/search.js +285 -0
- package/lib/settings.js +89 -0
- package/lib/types/client/NavOverlay.d.ts +18 -0
- package/lib/types/client/NavRail.d.ts +7 -0
- package/lib/types/client/QuestionNavSettingsTab.d.ts +16 -0
- package/lib/types/client/QuestionNavStrip.d.ts +48 -0
- package/lib/types/client/components/ConfigPanel.d.ts +14 -0
- package/lib/types/client/components/DotRail.d.ts +15 -0
- package/lib/types/client/components/NavOverlay.d.ts +30 -0
- package/lib/types/client/components/NavRail.d.ts +16 -0
- package/lib/types/client/components/RailDispatch.d.ts +8 -0
- package/lib/types/client/components/RailHost.d.ts +27 -0
- package/lib/types/client/components/SearchBox.d.ts +9 -0
- package/lib/types/client/components/SettingsPage.d.ts +9 -0
- package/lib/types/client/components/StandaloneSettings.d.ts +13 -0
- package/lib/types/client/components/codex/CodexRail.d.ts +3 -0
- package/lib/types/client/components/deepseek/DeepseekRail.d.ts +3 -0
- package/lib/types/client/components/minimal/MinimalRail.d.ts +8 -0
- package/lib/types/client/components/minimal/cards.d.ts +19 -0
- package/lib/types/client/components/minimal/dot.d.ts +14 -0
- package/lib/types/client/components/minimal/hint.d.ts +11 -0
- package/lib/types/client/components/minimal/paging.d.ts +10 -0
- package/lib/types/client/components/minimal/quick-settings.d.ts +8 -0
- package/lib/types/client/components/minimal/search.d.ts +27 -0
- package/lib/types/client/components/minimal/useRail.d.ts +90 -0
- package/lib/types/client/components/rail/cards.d.ts +19 -0
- package/lib/types/client/components/rail/hint.d.ts +11 -0
- package/lib/types/client/components/rail/paging.d.ts +10 -0
- package/lib/types/client/components/rail/quick-settings.d.ts +8 -0
- package/lib/types/client/components/rail/search.d.ts +34 -0
- package/lib/types/client/components/rail/useRail.d.ts +132 -0
- package/lib/types/client/components/settings/SettingsPage.d.ts +9 -0
- package/lib/types/client/components/settings/SettingsTab.d.ts +6 -0
- package/lib/types/client/components/settings/StandaloneSettings.d.ts +13 -0
- package/lib/types/client/components/shared/Cascade.d.ts +14 -0
- package/lib/types/client/components/shared/ConfigPanel.d.ts +8 -0
- package/lib/types/client/components/shared/Dot.d.ts +17 -0
- package/lib/types/client/components/shared/Hint.d.ts +6 -0
- package/lib/types/client/components/shared/PagingButton.d.ts +10 -0
- package/lib/types/client/components/shared/RailShell.d.ts +43 -0
- package/lib/types/client/components/shared/SearchBox.d.ts +18 -0
- package/lib/types/client/components/shared/hooks.d.ts +64 -0
- package/lib/types/client/components/shared/types.d.ts +101 -0
- package/lib/types/client/config.d.ts +9 -0
- package/lib/types/client/engines/jump.d.ts +17 -0
- package/lib/types/client/engines/useAtBottom.d.ts +1 -0
- package/lib/types/client/engines/useJump.d.ts +16 -0
- package/lib/types/client/engines/useScrollSpy.d.ts +12 -0
- package/lib/types/client/hooks.d.ts +7 -0
- package/lib/types/client/index.d.ts +31 -0
- package/lib/types/client/jump.d.ts +47 -0
- package/lib/types/client/locales.d.ts +142 -0
- package/lib/types/client/pure.d.ts +21 -0
- package/lib/types/client/settings.d.ts +173 -0
- package/lib/types/client/virtual/VirtualList.d.ts +15 -0
- package/lib/types/compat.d.ts +44 -0
- package/lib/types/config.d.ts +44 -0
- package/lib/types/core/active-dot.d.ts +15 -0
- package/lib/types/core/align.d.ts +10 -0
- package/lib/types/core/config.d.ts +44 -0
- package/lib/types/core/focus.d.ts +24 -0
- package/lib/types/core/jump.d.ts +39 -0
- package/lib/types/core/logic/active-dot.d.ts +15 -0
- package/lib/types/core/logic/focus.d.ts +22 -0
- package/lib/types/core/logic/jump.d.ts +39 -0
- package/lib/types/core/logic/nodes.d.ts +48 -0
- package/lib/types/core/model/nav-item.d.ts +12 -0
- package/lib/types/core/model/turns.d.ts +30 -0
- package/lib/types/core/nav-item.d.ts +12 -0
- package/lib/types/core/nodes.d.ts +43 -0
- package/lib/types/core/page-size.d.ts +8 -0
- package/lib/types/core/question-entry.d.ts +12 -0
- package/lib/types/core/time.d.ts +4 -0
- package/lib/types/core/turn-dots.d.ts +29 -0
- package/lib/types/core/utils/measure.d.ts +12 -0
- package/lib/types/core/utils/time.d.ts +4 -0
- package/lib/types/index.d.ts +4 -0
- package/lib/types/nav-item.d.ts +12 -0
- package/lib/types/nodes.d.ts +48 -0
- package/lib/types/projection.d.ts +71 -0
- package/lib/types/schema.d.ts +37 -0
- package/lib/types/search.d.ts +42 -0
- package/lib/types/settings.d.ts +99 -0
- package/package.json +71 -0
- package/src/client/components/RailDispatch.tsx +27 -0
- package/src/client/components/codex/CodexRail.tsx +348 -0
- package/src/client/components/deepseek/DeepseekRail.tsx +522 -0
- package/src/client/components/minimal/MinimalRail.tsx +324 -0
- package/src/client/components/minimal/dot.tsx +32 -0
- package/src/client/components/rail/cards.tsx +102 -0
- package/src/client/components/rail/hint.tsx +50 -0
- package/src/client/components/rail/paging.tsx +26 -0
- package/src/client/components/rail/quick-settings.tsx +130 -0
- package/src/client/components/rail/search.tsx +147 -0
- package/src/client/components/rail/useRail.ts +906 -0
- package/src/client/components/settings/SettingsPage.tsx +198 -0
- package/src/client/components/settings/StandaloneSettings.tsx +28 -0
- package/src/client/components/shared/types.ts +99 -0
- package/src/client/index.ts +388 -0
- package/src/client/jump.ts +293 -0
- package/src/client/locales.ts +143 -0
- package/src/client/settings.ts +482 -0
- package/src/client/styles.css +1580 -0
- package/src/compat.ts +161 -0
- package/src/index.ts +66 -0
- package/src/projection.ts +174 -0
- package/src/search.ts +291 -0
- package/src/settings.ts +169 -0
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Question-nav minimap. Renders a vertical column of small round dots overlaid
|
|
4
|
+
* on the LEFT edge of the conversation column (via the frame-wide
|
|
5
|
+
* `shell.overlay` floating layer): one dot per turn that claimed at least one
|
|
6
|
+
* user question — strictly aligned with the Trajectory view's turn numbering
|
|
7
|
+
* (turns without a question produce no dot). The dot column is vertically
|
|
8
|
+
* centered and clamped to at most 60% of the conversation height, scrolling
|
|
9
|
+
* within that band when the session has more dots than fit.
|
|
10
|
+
*
|
|
11
|
+
* Hovering a dot "selects" it: the selected dot plus its two immediate
|
|
12
|
+
* neighbors on each side magnify (progressively smaller with distance), and a
|
|
13
|
+
* vertical cascade of question cards opens — the selected (center) card is the
|
|
14
|
+
* focus (brand accent, elevated, full question text), the four neighbors are
|
|
15
|
+
* narrower context cards clamped to fewer lines. Every card is clickable and
|
|
16
|
+
* jumps to its question, exactly like clicking the dot. Hovering never scrolls
|
|
17
|
+
* the band — the dot column stays put while you browse.
|
|
18
|
+
*
|
|
19
|
+
* Overflow is paged, not auto-scrolled: two small triangle buttons in the dot
|
|
20
|
+
* style sit above and below the dot queue (▲ / ▼), each click revealing one
|
|
21
|
+
* page of hidden dots (the page size is configurable in the plugin settings,
|
|
22
|
+
* default 5) with a staggered pop-in animation as click feedback. The native
|
|
23
|
+
* scrollbar stays hidden; the triangles themselves are the overflow cue (each
|
|
24
|
+
* appears only while its direction has more to reveal) — no hover auto-scroll
|
|
25
|
+
* of any kind.
|
|
26
|
+
*
|
|
27
|
+
* A scroll-spy marks the reader's position: the dot of the turn at the
|
|
28
|
+
* reading line (35% down the scrollport) carries a ring marker that follows
|
|
29
|
+
* the conversation as it scrolls, streams, or pages its window. The marker
|
|
30
|
+
* never scrolls the band — it is purely a position indicator.
|
|
31
|
+
*
|
|
32
|
+
* Data source: the host-folded `msgNavMessages` session projection (whole
|
|
33
|
+
* history, persisted host-side, pushed live through session/projection
|
|
34
|
+
* frames) read through the injected `questionProjection` face, plus the live
|
|
35
|
+
* chat window's questions merged on top for the brief window before a
|
|
36
|
+
* just-sent question lands in the projection. No render-window expansion, no
|
|
37
|
+
* client-side history paging.
|
|
38
|
+
*
|
|
39
|
+
* Data arrives through the props shares: the framework `useSessions` hook
|
|
40
|
+
* (current session), the registrant inject face (read/subscribe/project/
|
|
41
|
+
* jump), and the bound locale translator.
|
|
42
|
+
*/
|
|
43
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
44
|
+
import { createPortal } from 'react-dom';
|
|
45
|
+
import { groupQuestionsByTurn, mergeLiveQuestions } from "../core/turn-dots.js";
|
|
46
|
+
import { DOT_GAP, DOT_SIZE, FOCUS_RADIUS, clampScrollTop, focusCardMetrics, focusScale, focusTier, pageStep } from "../core/focus.js";
|
|
47
|
+
import { pickActiveDot, readingLineOffset } from "../core/active-dot.js";
|
|
48
|
+
import { formatQuestionTime } from "../core/time.js";
|
|
49
|
+
const FAILURE_HINTS = {
|
|
50
|
+
VIEW_INACTIVE: 'jump.inactive',
|
|
51
|
+
TARGET_HIDDEN: 'jump.hidden',
|
|
52
|
+
NOT_FOUND: 'jump.notfound',
|
|
53
|
+
TIMEOUT: 'jump.timeout',
|
|
54
|
+
};
|
|
55
|
+
/** Read the projection face value as a question-entry list (structural guard). */
|
|
56
|
+
function projectionEntries(face) {
|
|
57
|
+
const value = face?.getSnapshot();
|
|
58
|
+
if (!Array.isArray(value))
|
|
59
|
+
return [];
|
|
60
|
+
return value.filter((item) => typeof item === 'object' && item !== null
|
|
61
|
+
&& typeof item.id === 'string'
|
|
62
|
+
&& typeof item.seq === 'number'
|
|
63
|
+
&& typeof item.turn === 'number');
|
|
64
|
+
}
|
|
65
|
+
function findConvRoot() {
|
|
66
|
+
return document.querySelector('[data-slot="conversation"] > div[data-phase]');
|
|
67
|
+
}
|
|
68
|
+
/** Structural equality of two dot lists (member keys fully capture a dot's
|
|
69
|
+
* folded questions, so identical key sequences mean identical content).
|
|
70
|
+
* Lets the strip skip a re-render when a refresh produced no change. */
|
|
71
|
+
function sameDots(a, b) {
|
|
72
|
+
if (a.length !== b.length)
|
|
73
|
+
return false;
|
|
74
|
+
for (let i = 0; i < a.length; i++) {
|
|
75
|
+
const da = a[i];
|
|
76
|
+
const db = b[i];
|
|
77
|
+
if (da.key !== db.key || da.memberKeys.length !== db.memberKeys.length)
|
|
78
|
+
return false;
|
|
79
|
+
for (let j = 0; j < da.memberKeys.length; j++) {
|
|
80
|
+
if (da.memberKeys[j] !== db.memberKeys[j])
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
export function QuestionNavStrip(props) {
|
|
87
|
+
const current = props.useSessions?.((s) => s.current);
|
|
88
|
+
const summary = props.useSessions?.((s) => (current === undefined ? undefined : s.byId?.[String(current)]));
|
|
89
|
+
const visible = current !== undefined && summary !== undefined && summary.blank !== true;
|
|
90
|
+
const [dots, setDots] = useState([]);
|
|
91
|
+
const [jumpingKey, setJumpingKey] = useState(null);
|
|
92
|
+
const [hint, setHint] = useState(null);
|
|
93
|
+
const [focus, setFocus] = useState(null);
|
|
94
|
+
// Scroll-spy: the dot marking the reader's current turn (null outside chat).
|
|
95
|
+
const [currentKey, setCurrentKey] = useState(null);
|
|
96
|
+
const [align, setAlign] = useState(() => props.align());
|
|
97
|
+
// Dots revealed per ▲/▼ click (settings scope; drives pageBy's step).
|
|
98
|
+
const [pageSize, setPageSize] = useState(() => props.pageSize());
|
|
99
|
+
const panelRef = useRef(null);
|
|
100
|
+
const listRef = useRef(null);
|
|
101
|
+
const hintTimerRef = useRef(null);
|
|
102
|
+
// Pending focus-clear timer: lets the mouse move from the rail onto a
|
|
103
|
+
// cascade card without dropping the cascade mid-flight.
|
|
104
|
+
const clearFocusTimerRef = useRef(null);
|
|
105
|
+
// Last rendered dot list, for the change-detection bail-out below.
|
|
106
|
+
const lastDotsRef = useRef([]);
|
|
107
|
+
// Whether the dot band overflows its 60% clamp (drives the ▲/▼ paging
|
|
108
|
+
// buttons).
|
|
109
|
+
const [scrollable, setScrollable] = useState(false);
|
|
110
|
+
// Scroll offset of the band + its max offset: drives the ▲/▼ visibility
|
|
111
|
+
// (each direction hides once there is nothing more to reveal).
|
|
112
|
+
const [scrollPos, setScrollPos] = useState({ top: 0, max: 0 });
|
|
113
|
+
// Dots revealed by the latest ▲/▼ click: key → stagger order + travel
|
|
114
|
+
// direction. Drives a short staggered pop-in animation so the click has a
|
|
115
|
+
// visible effect; cleared by a timer once the animation has played out.
|
|
116
|
+
const [revealed, setRevealed] = useState(null);
|
|
117
|
+
const revealTimerRef = useRef(null);
|
|
118
|
+
const syncScroll = () => {
|
|
119
|
+
const list = listRef.current;
|
|
120
|
+
if (list === null)
|
|
121
|
+
return;
|
|
122
|
+
const max = Math.max(0, list.scrollHeight - list.clientHeight);
|
|
123
|
+
setScrollPos({ top: list.scrollTop, max });
|
|
124
|
+
};
|
|
125
|
+
// Page the band by one DOT_PAGE_ROWS click in the given direction, then mark
|
|
126
|
+
// the dots the page just brought into view so they play a staggered pop-in
|
|
127
|
+
// animation in the direction of travel (▼ → rise from below, ▲ → drop from
|
|
128
|
+
// above) — the visual confirmation that the click revealed new dots.
|
|
129
|
+
const pageBy = (dir) => {
|
|
130
|
+
const list = listRef.current;
|
|
131
|
+
if (list === null)
|
|
132
|
+
return;
|
|
133
|
+
const max = Math.max(0, list.scrollHeight - list.clientHeight);
|
|
134
|
+
const target = clampScrollTop(list.scrollTop + dir * pageStep(DOT_SIZE, DOT_GAP, pageSize), max);
|
|
135
|
+
if (target === list.scrollTop)
|
|
136
|
+
return;
|
|
137
|
+
// Snapshot which dots are geometrically inside the band before the jump
|
|
138
|
+
// (the container rect does not move when its content scrolls).
|
|
139
|
+
const band = list.getBoundingClientRect();
|
|
140
|
+
const els = Array.from(list.querySelectorAll('[data-mega-chat-nav-key]'));
|
|
141
|
+
const wasVisible = els.map((el) => {
|
|
142
|
+
const r = el.getBoundingClientRect();
|
|
143
|
+
return r.bottom > band.top && r.top < band.bottom;
|
|
144
|
+
});
|
|
145
|
+
list.scrollTop = target;
|
|
146
|
+
syncScroll();
|
|
147
|
+
const next = new Map();
|
|
148
|
+
const fresh = [];
|
|
149
|
+
els.forEach((el, i) => {
|
|
150
|
+
if (wasVisible[i])
|
|
151
|
+
return;
|
|
152
|
+
const r = el.getBoundingClientRect();
|
|
153
|
+
const key = el.dataset.questionNavKey;
|
|
154
|
+
if (key !== undefined && r.bottom > band.top && r.top < band.bottom) {
|
|
155
|
+
fresh.push(key);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
// Stagger radiates from the clicked triangle: paging down (▼) starts at
|
|
159
|
+
// the bottom of the fresh batch, paging up (▲) at the top.
|
|
160
|
+
if (dir === 1)
|
|
161
|
+
fresh.reverse();
|
|
162
|
+
fresh.forEach((key, order) => next.set(key, { dir, order }));
|
|
163
|
+
if (next.size === 0)
|
|
164
|
+
return;
|
|
165
|
+
setRevealed(next);
|
|
166
|
+
if (revealTimerRef.current !== null)
|
|
167
|
+
window.clearTimeout(revealTimerRef.current);
|
|
168
|
+
// Animation (320ms) + per-dot stagger (35ms each) + slack.
|
|
169
|
+
revealTimerRef.current = window.setTimeout(() => setRevealed(null), 320 + next.size * 35 + 120);
|
|
170
|
+
};
|
|
171
|
+
const canPageUp = scrollable && scrollPos.top > 0;
|
|
172
|
+
const canPageDown = scrollable && scrollPos.top < scrollPos.max;
|
|
173
|
+
// Focus persists briefly after leaving the rail, so the mouse can reach the
|
|
174
|
+
// clickable cascade cards; entering a card cancels the clear, leaving
|
|
175
|
+
// everything re-arms it.
|
|
176
|
+
const cancelClearFocus = () => {
|
|
177
|
+
if (clearFocusTimerRef.current !== null) {
|
|
178
|
+
window.clearTimeout(clearFocusTimerRef.current);
|
|
179
|
+
clearFocusTimerRef.current = null;
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
const scheduleClearFocus = () => {
|
|
183
|
+
cancelClearFocus();
|
|
184
|
+
clearFocusTimerRef.current = window.setTimeout(() => setFocus(null), 240);
|
|
185
|
+
};
|
|
186
|
+
// Follow the plugin settings from the settings scope (an align change
|
|
187
|
+
// re-anchors the rail through the layout effect below; a page-size change
|
|
188
|
+
// steps the ▲/▼ paging).
|
|
189
|
+
useEffect(() => props.subscribeSettings(() => {
|
|
190
|
+
setAlign(props.align());
|
|
191
|
+
setPageSize(props.pageSize());
|
|
192
|
+
}), [props]);
|
|
193
|
+
const showHint = (message) => {
|
|
194
|
+
setHint(message);
|
|
195
|
+
if (hintTimerRef.current !== null)
|
|
196
|
+
window.clearTimeout(hintTimerRef.current);
|
|
197
|
+
hintTimerRef.current = window.setTimeout(() => setHint(null), 1800);
|
|
198
|
+
};
|
|
199
|
+
// Recompute the dot list from the projection + live window. Subscribed to
|
|
200
|
+
// the projection push frames and the session's content only — session
|
|
201
|
+
// switches are covered by `current` below (the effect re-runs on change),
|
|
202
|
+
// so the session-list feed is not subscribed: it would re-run the full
|
|
203
|
+
// recompute for unrelated list churn.
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
if (!visible || current === undefined) {
|
|
206
|
+
setDots([]);
|
|
207
|
+
lastDotsRef.current = [];
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const sessionId = current;
|
|
211
|
+
const face = props.questionProjection(sessionId);
|
|
212
|
+
const refresh = () => {
|
|
213
|
+
const grouped = groupQuestionsByTurn(projectionEntries(face));
|
|
214
|
+
const next = mergeLiveQuestions(grouped, props.readQuestions(sessionId));
|
|
215
|
+
// Identical content (a streaming update that added no question): re-use
|
|
216
|
+
// the previous array reference so React bails out of re-rendering the
|
|
217
|
+
// strip — the common case during assistant streaming.
|
|
218
|
+
if (sameDots(next, lastDotsRef.current)) {
|
|
219
|
+
setDots(lastDotsRef.current);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
lastDotsRef.current = next;
|
|
223
|
+
setDots(next);
|
|
224
|
+
};
|
|
225
|
+
refresh();
|
|
226
|
+
const unsubProjection = face?.subscribe(refresh) ?? (() => { });
|
|
227
|
+
const unsubContent = props.subscribeContent(sessionId, refresh);
|
|
228
|
+
return () => {
|
|
229
|
+
unsubProjection();
|
|
230
|
+
unsubContent();
|
|
231
|
+
};
|
|
232
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
233
|
+
}, [visible, current, props]);
|
|
234
|
+
// Listen for jump-failure events and surface the hint.
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
const onJumpFailed = (event) => {
|
|
237
|
+
const code = event.detail;
|
|
238
|
+
showHint(props.t(FAILURE_HINTS[code] ?? 'jump.timeout'));
|
|
239
|
+
};
|
|
240
|
+
window.addEventListener('mega-chat-nav:jump-failed', onJumpFailed);
|
|
241
|
+
return () => window.removeEventListener('mega-chat-nav:jump-failed', onJumpFailed);
|
|
242
|
+
}, [props]);
|
|
243
|
+
// Anchor the minimap to the conversation column: position it at the left
|
|
244
|
+
// edge of the conversation root and reserve a thin rail with padding-left.
|
|
245
|
+
//
|
|
246
|
+
// A layout-correction loop keeps the rail pinned to the conversation even
|
|
247
|
+
// when an outside panel (e.g. a browser-extension sidebar like Doubao) moves
|
|
248
|
+
// or resizes the frame without resizing the browser window: `window resize`
|
|
249
|
+
// never fires for in-page panels, and a ResizeObserver on the conversation
|
|
250
|
+
// root alone misses remounts and the tail of the frame's grid-column
|
|
251
|
+
// transition, which used to leave the rail drifting into the session list.
|
|
252
|
+
// The loop runs while the layout is still moving, then parks itself after a
|
|
253
|
+
// few stable frames; observers re-wake it on the next change.
|
|
254
|
+
useLayoutEffect(() => {
|
|
255
|
+
if (!visible)
|
|
256
|
+
return;
|
|
257
|
+
let raf = 0;
|
|
258
|
+
let idle = 0;
|
|
259
|
+
let stopped = false;
|
|
260
|
+
let observer = null;
|
|
261
|
+
const observed = { frame: null, convRoot: null };
|
|
262
|
+
const applyLayout = () => {
|
|
263
|
+
const panel = panelRef.current;
|
|
264
|
+
if (panel === null)
|
|
265
|
+
return false;
|
|
266
|
+
const frame = panel.closest('[data-shell-overlay]')?.parentElement ?? null;
|
|
267
|
+
const convRoot = findConvRoot();
|
|
268
|
+
// Keep looping while the anchors are not both present (conversation not
|
|
269
|
+
// mounted yet / mid-reflow), so a late mount still aligns.
|
|
270
|
+
if (frame === null || convRoot === null)
|
|
271
|
+
return true;
|
|
272
|
+
// Keep observing the live nodes: the conversation root may remount
|
|
273
|
+
// (e.g. after a panel-triggered reflow), which silently detaches an
|
|
274
|
+
// earlier ResizeObserver target.
|
|
275
|
+
if (observer !== null) {
|
|
276
|
+
if (observed.frame !== frame) {
|
|
277
|
+
observer.observe(frame, { box: 'border-box' });
|
|
278
|
+
observed.frame = frame;
|
|
279
|
+
}
|
|
280
|
+
if (observed.convRoot !== convRoot) {
|
|
281
|
+
observer.observe(convRoot, { box: 'border-box' });
|
|
282
|
+
observed.convRoot = convRoot;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const frameRect = frame.getBoundingClientRect();
|
|
286
|
+
const convRect = convRoot.getBoundingClientRect();
|
|
287
|
+
// Never snap onto a transient box: keep correcting until the
|
|
288
|
+
// conversation has a real footprint again.
|
|
289
|
+
if (convRect.height <= 0 || convRect.width <= 0)
|
|
290
|
+
return true;
|
|
291
|
+
const top = `${convRect.top - frameRect.top}px`;
|
|
292
|
+
const height = `${convRect.height}px`;
|
|
293
|
+
// Anchor to the configured edge: keep the other edge's inline style
|
|
294
|
+
// cleared so the CSS class (left: auto on .railRight) owns it.
|
|
295
|
+
if (align === 'right') {
|
|
296
|
+
// Give back the conversation scrollbar's layout width so the 44px
|
|
297
|
+
// rail never covers the bar: the WebKit path consumes 8px at the
|
|
298
|
+
// column's right edge, which the rail would otherwise render on top
|
|
299
|
+
// of, making the bar unclickable. ui-theme mirrors that width as
|
|
300
|
+
// --dsh-scrollbar-width (the overlay composer seat offsets `right`
|
|
301
|
+
// by the same variable); 8px fallback covers engines where the
|
|
302
|
+
// variable is absent.
|
|
303
|
+
const right = `calc(${frameRect.right - convRect.right}px + var(--dsh-scrollbar-width, 8px))`;
|
|
304
|
+
if (panel.style.top === top && panel.style.height === height
|
|
305
|
+
&& panel.style.right === right && panel.style.left === '') {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
panel.style.top = top;
|
|
309
|
+
panel.style.height = height;
|
|
310
|
+
panel.style.right = right;
|
|
311
|
+
panel.style.left = '';
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
const left = `${convRect.left - frameRect.left}px`;
|
|
315
|
+
if (panel.style.top === top && panel.style.height === height
|
|
316
|
+
&& panel.style.left === left && panel.style.right === '') {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
panel.style.top = top;
|
|
320
|
+
panel.style.height = height;
|
|
321
|
+
panel.style.left = left;
|
|
322
|
+
panel.style.right = '';
|
|
323
|
+
return true;
|
|
324
|
+
};
|
|
325
|
+
const loop = () => {
|
|
326
|
+
if (stopped)
|
|
327
|
+
return;
|
|
328
|
+
raf = 0;
|
|
329
|
+
idle = applyLayout() ? 0 : idle + 1;
|
|
330
|
+
if (idle < 3)
|
|
331
|
+
raf = requestAnimationFrame(loop);
|
|
332
|
+
};
|
|
333
|
+
const wake = () => {
|
|
334
|
+
if (stopped)
|
|
335
|
+
return;
|
|
336
|
+
idle = 0;
|
|
337
|
+
if (raf === 0)
|
|
338
|
+
raf = requestAnimationFrame(loop);
|
|
339
|
+
};
|
|
340
|
+
observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(wake);
|
|
341
|
+
if (observer !== null) {
|
|
342
|
+
const panel = panelRef.current;
|
|
343
|
+
const frame = panel?.closest('[data-shell-overlay]')?.parentElement ?? null;
|
|
344
|
+
const convRoot = findConvRoot();
|
|
345
|
+
if (frame !== null) {
|
|
346
|
+
observer.observe(frame, { box: 'border-box' });
|
|
347
|
+
observed.frame = frame;
|
|
348
|
+
}
|
|
349
|
+
if (convRoot !== null) {
|
|
350
|
+
observer.observe(convRoot, { box: 'border-box' });
|
|
351
|
+
observed.convRoot = convRoot;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// Initial alignment; the loop keeps correcting through layout transitions.
|
|
355
|
+
wake();
|
|
356
|
+
window.addEventListener('resize', wake);
|
|
357
|
+
return () => {
|
|
358
|
+
stopped = true;
|
|
359
|
+
if (raf !== 0)
|
|
360
|
+
cancelAnimationFrame(raf);
|
|
361
|
+
observer?.disconnect();
|
|
362
|
+
window.removeEventListener('resize', wake);
|
|
363
|
+
};
|
|
364
|
+
}, [visible, align]);
|
|
365
|
+
// Detect band overflow: drives the ▲/▼ paging buttons. Re-checked when the
|
|
366
|
+
// dots change and whenever the band itself resizes (the layout loop above
|
|
367
|
+
// clamps it to the conversation height).
|
|
368
|
+
useLayoutEffect(() => {
|
|
369
|
+
const list = listRef.current;
|
|
370
|
+
if (list === null)
|
|
371
|
+
return;
|
|
372
|
+
const check = () => {
|
|
373
|
+
setScrollable(list.scrollHeight > list.clientHeight + 1);
|
|
374
|
+
syncScroll();
|
|
375
|
+
};
|
|
376
|
+
check();
|
|
377
|
+
if (typeof ResizeObserver === 'undefined')
|
|
378
|
+
return;
|
|
379
|
+
const observer = new ResizeObserver(check);
|
|
380
|
+
observer.observe(list);
|
|
381
|
+
return () => observer.disconnect();
|
|
382
|
+
}, [dots, align]);
|
|
383
|
+
// Scroll-spy: mark the dot whose turn sits at the reader's position and
|
|
384
|
+
// follow the conversation as it scrolls. The reading line is 35% down the
|
|
385
|
+
// scrollport (see core/active-dot.ts), so the marker reads as "the question
|
|
386
|
+
// I am looking at". Only rendered rows participate: a paged-out window
|
|
387
|
+
// unmounts older questions, and the marker tracks what is on screen.
|
|
388
|
+
useEffect(() => {
|
|
389
|
+
if (!visible || dots.length === 0) {
|
|
390
|
+
setCurrentKey(null);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
let raf = 0;
|
|
394
|
+
let disposed = false;
|
|
395
|
+
let observedFlow = null;
|
|
396
|
+
const compute = () => {
|
|
397
|
+
raf = 0;
|
|
398
|
+
const scrollport = document.querySelector('[data-conversation-scroll]');
|
|
399
|
+
const flow = document.querySelector('[data-chat-flow]');
|
|
400
|
+
// Trajectory view / chat not mounted: no marker.
|
|
401
|
+
if (scrollport === null || flow === null) {
|
|
402
|
+
setCurrentKey(null);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
// Content growth (streaming, paging) shifts row tops without a scroll
|
|
406
|
+
// event; re-observe the flow element across remounts.
|
|
407
|
+
if (observer !== null && observedFlow !== flow) {
|
|
408
|
+
observer.disconnect();
|
|
409
|
+
observer.observe(flow);
|
|
410
|
+
observedFlow = flow;
|
|
411
|
+
}
|
|
412
|
+
const portRect = scrollport.getBoundingClientRect();
|
|
413
|
+
if (portRect.height <= 0)
|
|
414
|
+
return;
|
|
415
|
+
// Key lookup without interpolating a selector (chat keys contain ':').
|
|
416
|
+
const rendered = new Map();
|
|
417
|
+
for (const row of flow.querySelectorAll('[data-chat-anchor-key]')) {
|
|
418
|
+
const key = row.dataset.chatAnchorKey;
|
|
419
|
+
if (key !== undefined && !rendered.has(key))
|
|
420
|
+
rendered.set(key, row);
|
|
421
|
+
}
|
|
422
|
+
const rows = [];
|
|
423
|
+
for (const dot of dots) {
|
|
424
|
+
// The turn's first rendered question row anchors the dot (memberKeys
|
|
425
|
+
// are in seq order, so the first hit is the turn's topmost row).
|
|
426
|
+
for (const memberKey of dot.memberKeys) {
|
|
427
|
+
const row = rendered.get(memberKey);
|
|
428
|
+
if (row !== undefined) {
|
|
429
|
+
rows.push({ key: dot.key, top: row.getBoundingClientRect().top - portRect.top });
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
const next = pickActiveDot(rows, readingLineOffset(portRect.height));
|
|
435
|
+
setCurrentKey((prev) => (prev === next ? prev : next));
|
|
436
|
+
};
|
|
437
|
+
const schedule = () => {
|
|
438
|
+
if (!disposed && raf === 0)
|
|
439
|
+
raf = requestAnimationFrame(compute);
|
|
440
|
+
};
|
|
441
|
+
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(schedule);
|
|
442
|
+
compute();
|
|
443
|
+
// Scroll events do not bubble; capturing at the document reaches the
|
|
444
|
+
// conversation scrollport today and after any remount without tracking
|
|
445
|
+
// the node. rAF-throttled, so hot scroll streams coalesce into one
|
|
446
|
+
// measurement per frame.
|
|
447
|
+
document.addEventListener('scroll', schedule, true);
|
|
448
|
+
window.addEventListener('resize', schedule);
|
|
449
|
+
return () => {
|
|
450
|
+
disposed = true;
|
|
451
|
+
if (raf !== 0)
|
|
452
|
+
cancelAnimationFrame(raf);
|
|
453
|
+
observer?.disconnect();
|
|
454
|
+
document.removeEventListener('scroll', schedule, true);
|
|
455
|
+
window.removeEventListener('resize', schedule);
|
|
456
|
+
};
|
|
457
|
+
}, [visible, dots]);
|
|
458
|
+
// Clear any pending timers on unmount.
|
|
459
|
+
useEffect(() => () => {
|
|
460
|
+
if (hintTimerRef.current !== null)
|
|
461
|
+
window.clearTimeout(hintTimerRef.current);
|
|
462
|
+
if (clearFocusTimerRef.current !== null)
|
|
463
|
+
window.clearTimeout(clearFocusTimerRef.current);
|
|
464
|
+
if (revealTimerRef.current !== null)
|
|
465
|
+
window.clearTimeout(revealTimerRef.current);
|
|
466
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
467
|
+
}, []);
|
|
468
|
+
if (!visible)
|
|
469
|
+
return null;
|
|
470
|
+
const onJump = (dot) => {
|
|
471
|
+
if (current === undefined)
|
|
472
|
+
return;
|
|
473
|
+
setJumpingKey(dot.key);
|
|
474
|
+
props.jump(current, dot.key);
|
|
475
|
+
window.setTimeout(() => setJumpingKey((k) => (k === dot.key ? null : k)), 600);
|
|
476
|
+
};
|
|
477
|
+
const openFocus = (dot, target) => {
|
|
478
|
+
const index = dots.findIndex((d) => d.key === dot.key);
|
|
479
|
+
if (index < 0)
|
|
480
|
+
return;
|
|
481
|
+
cancelClearFocus();
|
|
482
|
+
const r = target.getBoundingClientRect();
|
|
483
|
+
// The cascade opens away from the rail: to the right of the dots on a
|
|
484
|
+
// left-anchored rail, to the left on a right-anchored one. The column is
|
|
485
|
+
// vertically centered on the selected dot (top: centerY + translateY(-50%)).
|
|
486
|
+
const horizontal = align === 'right'
|
|
487
|
+
? { right: window.innerWidth - r.left + 10 }
|
|
488
|
+
: { left: r.right + 10 };
|
|
489
|
+
const lo = Math.max(0, index - FOCUS_RADIUS);
|
|
490
|
+
const hi = Math.min(dots.length - 1, index + FOCUS_RADIUS);
|
|
491
|
+
const items = [];
|
|
492
|
+
for (let i = lo; i <= hi; i++) {
|
|
493
|
+
items.push({ dot: dots[i], distance: Math.abs(i - index) });
|
|
494
|
+
}
|
|
495
|
+
setFocus({ key: dot.key, items, centerY: r.top + r.height / 2, ...horizontal });
|
|
496
|
+
};
|
|
497
|
+
const t = props.t;
|
|
498
|
+
const selectedIndex = focus === null ? -1 : dots.findIndex((d) => d.key === focus.key);
|
|
499
|
+
return (_jsxs("div", { ref: panelRef, className: align === 'right' ? `${'mgs-rail'} ${'mgs-railRight'}` : 'mgs-rail', "data-mega-chat-nav": "rail", children: [_jsx("div", { className: 'mgs-queue', children: dots.length === 0 ? (_jsx("div", { className: 'mgs-empty', children: t('strip.empty') })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: 'mgs-count', children: dots.length }), scrollable ? (_jsx("button", { type: "button", className: `${'mgs-navBtn'} ${'mgs-navUp'}`, "aria-label": t('strip.up'), style: { visibility: canPageUp ? 'visible' : 'hidden' }, onMouseEnter: cancelClearFocus, onMouseLeave: scheduleClearFocus, onClick: () => pageBy(-1) })) : null, _jsx("div", { ref: listRef, className: 'mgs-list', onScroll: syncScroll, onMouseLeave: scheduleClearFocus, children: _jsx("div", { className: 'mgs-dots', children: dots.map((dot, index) => {
|
|
500
|
+
const isFocused = focus !== null && focus.key === dot.key;
|
|
501
|
+
// Progressive magnification: selected largest, ±1 smaller, ±2
|
|
502
|
+
// smaller still, the rest base scale.
|
|
503
|
+
const tier = selectedIndex < 0 ? null : focusTier(index - selectedIndex);
|
|
504
|
+
const scale = jumpingKey === dot.key ? 1.6 : tier !== null ? focusScale(tier) : 1;
|
|
505
|
+
// Click-paging feedback: dots just paged into view play a
|
|
506
|
+
// staggered pop-in, traveling in the paging direction.
|
|
507
|
+
const reveal = revealed?.get(dot.key);
|
|
508
|
+
const cls = ['mgs-dot'];
|
|
509
|
+
if (isFocused)
|
|
510
|
+
cls.push('mgs-focused');
|
|
511
|
+
if (jumpingKey === dot.key)
|
|
512
|
+
cls.push('mgs-active');
|
|
513
|
+
if (currentKey === dot.key)
|
|
514
|
+
cls.push('mgs-current');
|
|
515
|
+
if (reveal !== undefined) {
|
|
516
|
+
cls.push(reveal.dir === 1 ? 'mgs-dotEnterFromBottom' : 'mgs-dotEnterFromTop');
|
|
517
|
+
}
|
|
518
|
+
return (_jsx("button", { className: cls.join(' '), style: {
|
|
519
|
+
transform: `scale(${scale})`,
|
|
520
|
+
...(reveal !== undefined ? { animationDelay: `${reveal.order * 35}ms` } : {}),
|
|
521
|
+
}, "data-mega-chat-nav-focused": isFocused ? 'true' : undefined, "data-mega-chat-nav-index": index, "data-mega-chat-nav-key": dot.key, "aria-label": dot.texts[0] ?? '', onMouseEnter: (e) => openFocus(dot, e.currentTarget), onClick: () => onJump(dot) }, dot.key));
|
|
522
|
+
}) }) }), scrollable ? (_jsx("button", { type: "button", className: `${'mgs-navBtn'} ${'mgs-navDown'}`, "aria-label": t('strip.down'), style: { visibility: canPageDown ? 'visible' : 'hidden' }, onMouseEnter: cancelClearFocus, onMouseLeave: scheduleClearFocus, onClick: () => pageBy(1) })) : null] })) }), hint !== null
|
|
523
|
+
? createPortal(_jsx("div", { className: 'mgs-hint', style: align === 'right' ? { right: 60, top: 20 } : { left: 60, top: 20 }, children: hint }), document.body)
|
|
524
|
+
: null, focus !== null
|
|
525
|
+
? createPortal(
|
|
526
|
+
/* The vertical cascade: one crisp card per window dot, stacked
|
|
527
|
+
non-overlapping, vertically centered on the selected dot.
|
|
528
|
+
Hovering the cascade keeps it alive (the rail only re-arms its
|
|
529
|
+
clear once the mouse leaves both). */
|
|
530
|
+
_jsx("div", { className: align === 'right' ? `${'mgs-cascade'} ${'mgs-cascadeRight'}` : 'mgs-cascade', style: {
|
|
531
|
+
...(focus.left !== undefined ? { left: focus.left } : {}),
|
|
532
|
+
...(focus.right !== undefined ? { right: focus.right } : {}),
|
|
533
|
+
top: focus.centerY,
|
|
534
|
+
transform: 'translateY(-50%)',
|
|
535
|
+
}, onMouseEnter: cancelClearFocus, onMouseLeave: scheduleClearFocus, children: focus.items.map((item) => {
|
|
536
|
+
const metrics = focusCardMetrics(item.distance);
|
|
537
|
+
if (metrics === null)
|
|
538
|
+
return null;
|
|
539
|
+
const isSelected = item.distance === 0;
|
|
540
|
+
// Cascade cards join their dot's texts into one clamped line;
|
|
541
|
+
// the selected card keeps every line. Every card shows the
|
|
542
|
+
// question's sent time, and clicking any card jumps to its
|
|
543
|
+
// question exactly like clicking the dot.
|
|
544
|
+
const text = item.dot.texts.join(' · ');
|
|
545
|
+
const cls = isSelected
|
|
546
|
+
? `${'mgs-card'} ${'mgs-cardSelected'}${align === 'right' ? ` ${'mgs-cardSelectedRight'}` : ''}`
|
|
547
|
+
: 'mgs-card';
|
|
548
|
+
return (_jsxs("div", { className: cls, role: "button", tabIndex: -1, style: { width: metrics.widthPx }, onClick: () => onJump(item.dot), children: [isSelected && item.dot.turn !== null ? (_jsxs("div", { className: 'mgs-cardTitle', children: ["Turn ", item.dot.turn] })) : null, _jsx("div", { className: isSelected ? 'mgs-cardBody' : 'mgs-cardClamp', style: isSelected
|
|
549
|
+
? undefined
|
|
550
|
+
: { WebkitLineClamp: metrics.maxLines, fontSize: metrics.fontSize }, children: isSelected
|
|
551
|
+
? item.dot.texts.map((line, lineIndex) => (_jsx("div", { className: 'mgs-cardLine', children: line }, lineIndex)))
|
|
552
|
+
: text }), _jsx("div", { className: 'mgs-cardTime', children: formatQuestionTime(item.dot.time, Date.now()) })] }, item.dot.key));
|
|
553
|
+
}) }), document.body)
|
|
554
|
+
: null] }));
|
|
555
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { readConfig, setGeneral } from "../config.js";
|
|
3
|
+
const STYLES = ['minimal', 'deepseek', 'codex'];
|
|
4
|
+
export function ConfigPanel(props) {
|
|
5
|
+
const cfg = readConfig(props.scope);
|
|
6
|
+
if (!cfg || !props.scope)
|
|
7
|
+
return null;
|
|
8
|
+
const g = cfg.general;
|
|
9
|
+
const set = (patch) => void setGeneral(props.scope, patch);
|
|
10
|
+
return (_jsxs("div", { className: "mgs-panel mgs-config", role: "dialog", "aria-label": props.t('settings.entry'), children: [_jsxs("div", { className: "mgs-panel__head", children: [_jsx("span", { className: "mgs-panel__title", children: props.t('settings.entry') }), _jsx("button", { type: "button", className: "mgs-rail__toggle", onClick: props.onClose, "aria-label": "close", children: "\u2715" })] }), _jsxs("div", { className: "mgs-row", children: [_jsx("span", { className: "mgs-row__label", children: props.t('config.style') }), _jsx("div", { className: "mgs-seg", children: STYLES.map((s) => (_jsx("button", { type: "button", className: 'mgs-seg__item' + (g.style === s ? ' mgs-seg__item--active' : ''), onClick: () => set({ style: s }), children: props.t('config.style.' + s) }, s))) })] }), _jsxs("div", { className: "mgs-row", children: [_jsx("span", { className: "mgs-row__label", children: props.t('config.align') }), _jsx("div", { className: "mgs-seg", children: ['left', 'right'].map((a) => (_jsx("button", { type: "button", className: 'mgs-seg__item' + (g.align === a ? ' mgs-seg__item--active' : ''), onClick: () => set({ align: a }), children: props.t('config.align.' + a) }, a))) })] }), _jsxs("div", { className: "mgs-row", children: [_jsx("span", { className: "mgs-row__label", children: props.t('config.panelWidth') }), _jsx("input", { className: "mgs-input mgs-input--num", type: "number", min: 200, max: 400, value: g.panelWidth, onChange: (e) => set({ panelWidth: Math.min(400, Math.max(200, Number(e.target.value) || 280)) }) })] }), _jsxs("div", { className: "mgs-row", children: [_jsx("span", { className: "mgs-row__label", children: props.t('config.collapsed') }), _jsx(Switch, { checked: g.collapsed, onChange: (v) => set({ collapsed: v }) })] }), _jsxs("div", { className: "mgs-row", children: [_jsx("span", { className: "mgs-row__label", children: props.t('config.showSearch') }), _jsx(Switch, { checked: g.showSearch, onChange: (v) => set({ showSearch: v }) })] }), _jsxs("div", { className: "mgs-row", children: [_jsx("span", { className: "mgs-row__label", children: props.t('config.themeMode') }), _jsx("div", { className: "mgs-seg", children: ['follow', 'custom'].map((m) => (_jsx("button", { type: "button", className: 'mgs-seg__item' + (g.themeMode === m ? ' mgs-seg__item--active' : ''), onClick: () => set({ themeMode: m }), children: props.t('config.themeMode.' + m) }, m))) })] })] }));
|
|
11
|
+
}
|
|
12
|
+
export function Switch({ checked, onChange }) {
|
|
13
|
+
return (_jsxs("label", { className: "mgs-switch", children: [_jsx("input", { type: "checkbox", checked: checked, onChange: (e) => onChange(e.target.checked) }), _jsx("span", { className: "mgs-switch__track", children: _jsx("span", { className: "mgs-switch__thumb" }) })] }));
|
|
14
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// 圆点轨(纯渲染):question-nav 形态 —— 8px 圆点 / 波纹放大 / 悬停预览卡
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import { VirtualList } from "../virtual/VirtualList.js";
|
|
5
|
+
import { formatTime, truncate } from "../pure.js";
|
|
6
|
+
const DOT_STEP = 14;
|
|
7
|
+
const RIPPLE = [2.0, 1.55, 1.25];
|
|
8
|
+
function rippleScale(dist) {
|
|
9
|
+
if (dist < RIPPLE.length)
|
|
10
|
+
return RIPPLE[dist];
|
|
11
|
+
return 1;
|
|
12
|
+
}
|
|
13
|
+
function userItems(items) {
|
|
14
|
+
return items.filter((it) => it.role === 'user' || it.role === 'steering');
|
|
15
|
+
}
|
|
16
|
+
export function DotRail(props) {
|
|
17
|
+
const { items, align, cfg, t, activeKey, failed, onJump, pos } = props;
|
|
18
|
+
const entries = useMemo(() => userItems(items), [items]);
|
|
19
|
+
const [scrollTop, setScrollTop] = useState(0);
|
|
20
|
+
const [viewportH, setViewportH] = useState(360);
|
|
21
|
+
const [hoverKey, setHoverKey] = useState(null);
|
|
22
|
+
const [hoverIdx, setHoverIdx] = useState(-1);
|
|
23
|
+
const trackRef = useRef(null);
|
|
24
|
+
const keys = entries.map((it) => it.key);
|
|
25
|
+
const hovered = hoverIdx >= 0 ? entries[hoverIdx] : undefined;
|
|
26
|
+
const pinned = useMemo(() => {
|
|
27
|
+
const s = new Set();
|
|
28
|
+
if (activeKey)
|
|
29
|
+
s.add(activeKey);
|
|
30
|
+
if (hoverKey)
|
|
31
|
+
s.add(hoverKey);
|
|
32
|
+
return s.size > 0 ? s : undefined;
|
|
33
|
+
}, [activeKey, hoverKey]);
|
|
34
|
+
// 视口高度跟随轨道容器
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const el = trackRef.current;
|
|
37
|
+
if (!el)
|
|
38
|
+
return;
|
|
39
|
+
const ro = new ResizeObserver(() => {
|
|
40
|
+
const h = el.clientHeight;
|
|
41
|
+
setViewportH((prev) => (Math.abs(prev - h) > 1 ? h : prev));
|
|
42
|
+
});
|
|
43
|
+
ro.observe(el);
|
|
44
|
+
setViewportH(el.clientHeight || 360);
|
|
45
|
+
return () => ro.disconnect();
|
|
46
|
+
}, []);
|
|
47
|
+
const style = {
|
|
48
|
+
position: 'fixed',
|
|
49
|
+
top: pos?.top ?? '50%',
|
|
50
|
+
height: pos?.height ?? undefined,
|
|
51
|
+
left: pos?.left,
|
|
52
|
+
right: pos?.right,
|
|
53
|
+
};
|
|
54
|
+
if (!pos)
|
|
55
|
+
style.transform = 'translateY(-50%)';
|
|
56
|
+
return (_jsxs("div", { className: 'mgs-dots mgs-dots--' + align, style: style, role: "navigation", "aria-label": t('a11y.list'), children: [_jsx("div", { className: "mgs-dots__track", ref: trackRef, children: entries.length === 0 ? (_jsx("div", { className: "mgs-dots__empty", children: t('empty') })) : (_jsx(VirtualList, { items: entries, itemHeight: DOT_STEP, viewportHeight: viewportH, scrollTop: scrollTop, onScrollTopChange: setScrollTop, keyOf: (it) => it.key, pinned: pinned, renderItem: (it, i) => {
|
|
57
|
+
const dist = hoverIdx >= 0 ? Math.abs(i - hoverIdx) : 99;
|
|
58
|
+
return (_jsx("button", { type: "button", className: 'mgs-dot' +
|
|
59
|
+
(it.key === activeKey ? ' mgs-dot--active' : '') +
|
|
60
|
+
(it.key === hoverKey ? ' mgs-dot--hover' : ''), style: { transform: 'scale(' + rippleScale(dist) + ')' }, "aria-label": truncate(it.text, 40), "aria-current": it.key === activeKey ? 'step' : undefined, onClick: () => onJump(it.key), onMouseEnter: () => { setHoverKey(it.key); setHoverIdx(i); }, onMouseLeave: () => { setHoverKey((k) => (k === it.key ? null : k)); setHoverIdx(-1); } }));
|
|
61
|
+
} })) }), hovered && (_jsxs("div", { className: 'mgs-dots__tip mgs-dots__tip--' + align, children: [_jsx("div", { className: "mgs-dots__tip-text", children: truncate(hovered.text, 80) || t('empty') }), _jsx("div", { className: "mgs-dots__tip-time", children: formatTime(hovered.time) })] })), failed && _jsx("div", { className: "mgs-dots__hint", children: t('jump.failed') })] }));
|
|
62
|
+
}
|