@stream-mdx/react 0.3.0 → 0.5.0
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.md +14 -4
- package/README.md +65 -14
- package/dist/components/bottom-stick-scroll-area.cjs +344 -0
- package/dist/components/bottom-stick-scroll-area.d.cts +36 -0
- package/dist/components/bottom-stick-scroll-area.d.ts +36 -0
- package/dist/components/bottom-stick-scroll-area.mjs +320 -0
- package/dist/components/index.cjs +511 -176
- package/dist/components/index.d.cts +3 -1
- package/dist/components/index.d.ts +3 -1
- package/dist/components/index.mjs +358 -26
- package/dist/{index-D7px9jug.d.cts → index-BVhp_pcF.d.ts} +4 -1
- package/dist/{index-D7px9jug.d.ts → index-EaeouYp2.d.cts} +4 -1
- package/dist/index.cjs +2146 -1145
- package/dist/index.d.cts +6 -4
- package/dist/index.d.ts +6 -4
- package/dist/index.mjs +3835 -2836
- package/dist/mdx-client.cjs +1 -1
- package/dist/mdx-client.mjs +1 -1
- package/dist/mdx-coordinator.cjs +28 -7
- package/dist/mdx-coordinator.d.cts +2 -2
- package/dist/mdx-coordinator.d.ts +2 -2
- package/dist/mdx-coordinator.mjs +28 -7
- package/dist/renderer/node-views.cjs +273 -24
- package/dist/renderer/node-views.d.cts +3 -1
- package/dist/renderer/node-views.d.ts +3 -1
- package/dist/renderer/node-views.mjs +274 -25
- package/dist/renderer/patch-batching.cjs +2 -0
- package/dist/renderer/patch-batching.d.cts +1 -1
- package/dist/renderer/patch-batching.d.ts +1 -1
- package/dist/renderer/patch-batching.mjs +2 -1
- package/dist/renderer/patch-commit-scheduler.cjs +82 -30
- package/dist/renderer/patch-commit-scheduler.d.cts +15 -3
- package/dist/renderer/patch-commit-scheduler.d.ts +15 -3
- package/dist/renderer/patch-commit-scheduler.mjs +84 -30
- package/dist/renderer/store.cjs +382 -36
- package/dist/renderer/store.d.cts +3 -0
- package/dist/renderer/store.d.ts +3 -0
- package/dist/renderer/store.mjs +382 -36
- package/dist/renderer.cjs +3386 -2727
- package/dist/renderer.d.cts +15 -12
- package/dist/renderer.d.ts +15 -12
- package/dist/renderer.mjs +3410 -2751
- package/dist/server.cjs +4842 -0
- package/dist/server.d.cts +254 -0
- package/dist/server.d.ts +254 -0
- package/dist/server.mjs +4824 -0
- package/dist/{streaming-markdown-Dp1IDgMT.d.ts → streaming-markdown-CEI9eDZm.d.ts} +3 -1
- package/dist/{streaming-markdown-DSC4L0xR.d.cts → streaming-markdown-DRisClXb.d.cts} +3 -1
- package/dist/streaming-markdown.cjs +3476 -2794
- package/dist/streaming-markdown.d.cts +4 -2
- package/dist/streaming-markdown.d.ts +4 -2
- package/dist/streaming-markdown.mjs +3436 -2754
- package/dist/utils/inline-html.d.cts +3 -1
- package/dist/utils/inline-html.d.ts +3 -1
- package/package.json +19 -3
- package/shadcn/README.md +28 -0
- package/shadcn/bottom-stick-scroll-area.tsx +358 -0
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { I as InlineHtmlRendererMap } from '../index-EaeouYp2.cjs';
|
|
3
|
+
import '../components/bottom-stick-scroll-area.cjs';
|
|
4
|
+
import 'react/jsx-runtime';
|
|
3
5
|
import '@stream-mdx/core';
|
|
4
6
|
|
|
5
7
|
interface RenderInlineHtmlOptions {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { I as InlineHtmlRendererMap } from '../index-BVhp_pcF.js';
|
|
3
|
+
import '../components/bottom-stick-scroll-area.js';
|
|
4
|
+
import 'react/jsx-runtime';
|
|
3
5
|
import '@stream-mdx/core';
|
|
4
6
|
|
|
5
7
|
interface RenderInlineHtmlOptions {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stream-mdx/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "React renderer, scheduler, and worker wiring for the Streaming Markdown V2 pipeline",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -26,6 +26,21 @@
|
|
|
26
26
|
"import": "./dist/streaming-markdown.mjs",
|
|
27
27
|
"require": "./dist/streaming-markdown.cjs"
|
|
28
28
|
},
|
|
29
|
+
"./server": {
|
|
30
|
+
"types": "./dist/server.d.ts",
|
|
31
|
+
"import": "./dist/server.mjs",
|
|
32
|
+
"require": "./dist/server.cjs"
|
|
33
|
+
},
|
|
34
|
+
"./components": {
|
|
35
|
+
"types": "./dist/components/index.d.ts",
|
|
36
|
+
"import": "./dist/components/index.mjs",
|
|
37
|
+
"require": "./dist/components/index.cjs"
|
|
38
|
+
},
|
|
39
|
+
"./components/bottom-stick-scroll-area": {
|
|
40
|
+
"types": "./dist/components/bottom-stick-scroll-area.d.ts",
|
|
41
|
+
"import": "./dist/components/bottom-stick-scroll-area.mjs",
|
|
42
|
+
"require": "./dist/components/bottom-stick-scroll-area.cjs"
|
|
43
|
+
},
|
|
29
44
|
"./renderer": {
|
|
30
45
|
"types": "./dist/renderer/index.d.ts",
|
|
31
46
|
"import": "./dist/renderer/index.mjs",
|
|
@@ -74,6 +89,7 @@
|
|
|
74
89
|
},
|
|
75
90
|
"files": [
|
|
76
91
|
"dist",
|
|
92
|
+
"shadcn",
|
|
77
93
|
"README.md",
|
|
78
94
|
"CHANGELOG.md"
|
|
79
95
|
],
|
|
@@ -85,8 +101,8 @@
|
|
|
85
101
|
"prepack": "npm run build"
|
|
86
102
|
},
|
|
87
103
|
"dependencies": {
|
|
88
|
-
"@stream-mdx/core": "0.
|
|
89
|
-
"@stream-mdx/worker": "0.
|
|
104
|
+
"@stream-mdx/core": "0.5.0",
|
|
105
|
+
"@stream-mdx/worker": "0.5.0"
|
|
90
106
|
},
|
|
91
107
|
"devDependencies": {
|
|
92
108
|
"jsdom": "^26.0.0"
|
package/shadcn/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# ShadCN Drop-in: Bottom Stick Scroll Area
|
|
2
|
+
|
|
3
|
+
This folder contains a single-file `BottomStickScrollArea` component meant for copy/paste into app source trees (ShadCN style).
|
|
4
|
+
|
|
5
|
+
## File
|
|
6
|
+
|
|
7
|
+
- `bottom-stick-scroll-area.tsx`
|
|
8
|
+
|
|
9
|
+
## Copy steps
|
|
10
|
+
|
|
11
|
+
1. Copy `bottom-stick-scroll-area.tsx` into your app, for example:
|
|
12
|
+
- `components/ui/bottom-stick-scroll-area.tsx`
|
|
13
|
+
2. Keep/adjust these imports to match your app:
|
|
14
|
+
- `@/components/ui/button`
|
|
15
|
+
- `@/components/ui/scroll-area`
|
|
16
|
+
- `@/lib/utils`
|
|
17
|
+
3. Ensure dependencies are installed:
|
|
18
|
+
- `@radix-ui/react-scroll-area`
|
|
19
|
+
- `lucide-react`
|
|
20
|
+
|
|
21
|
+
## Behavior
|
|
22
|
+
|
|
23
|
+
- Sticks instantly to bottom while content streams (`STICKY_INSTANT`).
|
|
24
|
+
- Detaches when user scrolls upward (`DETACHED`).
|
|
25
|
+
- "Scroll to bottom" button appears with a short fade and performs smooth return (`RETURNING_SMOOTH`).
|
|
26
|
+
- Supports canceling return if the user scrolls during animation.
|
|
27
|
+
|
|
28
|
+
For package-first usage (no copy/paste), use `BottomStickScrollArea` from `@stream-mdx/react`.
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ShadCN-style drop-in component.
|
|
5
|
+
*
|
|
6
|
+
* Copy this file into your app (for example: `components/ui/bottom-stick-scroll-area.tsx`)
|
|
7
|
+
* and adjust import paths for `Button`, `ScrollBar`, and `cn` as needed.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
|
11
|
+
import { ChevronDown } from "lucide-react";
|
|
12
|
+
import type { ReactNode, UIEvent } from "react";
|
|
13
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
14
|
+
|
|
15
|
+
import { Button } from "@/components/ui/button";
|
|
16
|
+
import { ScrollBar } from "@/components/ui/scroll-area";
|
|
17
|
+
import { cn } from "@/lib/utils";
|
|
18
|
+
|
|
19
|
+
export type BottomStickMode = "STICKY_INSTANT" | "DETACHED" | "RETURNING_SMOOTH";
|
|
20
|
+
|
|
21
|
+
export type BottomStickDebugState = {
|
|
22
|
+
mode: BottomStickMode;
|
|
23
|
+
isOverflowing: boolean;
|
|
24
|
+
distanceToBottom: number;
|
|
25
|
+
scrollTop: number;
|
|
26
|
+
maxScrollTop: number;
|
|
27
|
+
scrollHeight: number;
|
|
28
|
+
clientHeight: number;
|
|
29
|
+
programmaticWrites: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type BottomStickScrollAreaProps = {
|
|
33
|
+
children: ReactNode;
|
|
34
|
+
className?: string;
|
|
35
|
+
contentClassName?: string;
|
|
36
|
+
showScrollBar?: boolean;
|
|
37
|
+
showJumpToBottom?: boolean;
|
|
38
|
+
onDebugStateChange?: (state: BottomStickDebugState) => void;
|
|
39
|
+
debugDomAttributes?: boolean;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const EPS_BOTTOM_PX = 2;
|
|
43
|
+
const EPS_OVERFLOW_PX = 1;
|
|
44
|
+
const DETACH_SCROLL_DELTA_PX = 4;
|
|
45
|
+
const RETURN_MIN_MS = 180;
|
|
46
|
+
const RETURN_MAX_MS = 520;
|
|
47
|
+
const RETURN_PX_PER_MS = 2.2;
|
|
48
|
+
const RETURN_SETTLE_FRAMES = 2;
|
|
49
|
+
|
|
50
|
+
function clamp01(value: number) {
|
|
51
|
+
if (value < 0) return 0;
|
|
52
|
+
if (value > 1) return 1;
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function easeOutCubic(value: number) {
|
|
57
|
+
return 1 - (1 - value) ** 3;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function measure(viewport: HTMLDivElement) {
|
|
61
|
+
const scrollTop = viewport.scrollTop;
|
|
62
|
+
const scrollHeight = viewport.scrollHeight;
|
|
63
|
+
const clientHeight = viewport.clientHeight;
|
|
64
|
+
const maxScrollTop = Math.max(0, scrollHeight - clientHeight);
|
|
65
|
+
const distanceToBottom = Math.max(0, maxScrollTop - scrollTop);
|
|
66
|
+
const isOverflowing = scrollHeight > clientHeight + EPS_OVERFLOW_PX;
|
|
67
|
+
|
|
68
|
+
return { scrollTop, scrollHeight, clientHeight, maxScrollTop, distanceToBottom, isOverflowing };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function BottomStickScrollArea({
|
|
72
|
+
children,
|
|
73
|
+
className,
|
|
74
|
+
contentClassName,
|
|
75
|
+
showScrollBar = true,
|
|
76
|
+
showJumpToBottom = true,
|
|
77
|
+
onDebugStateChange,
|
|
78
|
+
debugDomAttributes = false,
|
|
79
|
+
}: BottomStickScrollAreaProps) {
|
|
80
|
+
const viewportRef = useRef<HTMLDivElement>(null);
|
|
81
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
82
|
+
|
|
83
|
+
const modeRef = useRef<BottomStickMode>("STICKY_INSTANT");
|
|
84
|
+
const [mode, setMode] = useState<BottomStickMode>("STICKY_INSTANT");
|
|
85
|
+
const [isOverflowing, setIsOverflowing] = useState(false);
|
|
86
|
+
|
|
87
|
+
const prevScrollTopRef = useRef(0);
|
|
88
|
+
const lastProgrammaticWriteRef = useRef<{ at: number; top: number } | null>(null);
|
|
89
|
+
const programmaticWriteCountRef = useRef(0);
|
|
90
|
+
|
|
91
|
+
const pendingStickRafRef = useRef<number | null>(null);
|
|
92
|
+
const stickRequestedRef = useRef(false);
|
|
93
|
+
|
|
94
|
+
const returnRafRef = useRef<number | null>(null);
|
|
95
|
+
const returnAnimRef = useRef<{
|
|
96
|
+
startTime: number;
|
|
97
|
+
startTop: number;
|
|
98
|
+
durationMs: number;
|
|
99
|
+
settleFrames: number;
|
|
100
|
+
} | null>(null);
|
|
101
|
+
|
|
102
|
+
const setModeBoth = useCallback((next: BottomStickMode) => {
|
|
103
|
+
modeRef.current = next;
|
|
104
|
+
setMode(next);
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
107
|
+
const updateDebug = useCallback(() => {
|
|
108
|
+
const viewport = viewportRef.current;
|
|
109
|
+
if (!viewport) return;
|
|
110
|
+
|
|
111
|
+
const m = measure(viewport);
|
|
112
|
+
if (debugDomAttributes) {
|
|
113
|
+
viewport.dataset.stickyMode = modeRef.current;
|
|
114
|
+
viewport.dataset.distanceToBottom = String(m.distanceToBottom);
|
|
115
|
+
viewport.dataset.isOverflowing = m.isOverflowing ? "1" : "0";
|
|
116
|
+
viewport.dataset.programmaticWrites = String(programmaticWriteCountRef.current);
|
|
117
|
+
}
|
|
118
|
+
onDebugStateChange?.({
|
|
119
|
+
mode: modeRef.current,
|
|
120
|
+
isOverflowing: m.isOverflowing,
|
|
121
|
+
distanceToBottom: m.distanceToBottom,
|
|
122
|
+
scrollTop: m.scrollTop,
|
|
123
|
+
maxScrollTop: m.maxScrollTop,
|
|
124
|
+
scrollHeight: m.scrollHeight,
|
|
125
|
+
clientHeight: m.clientHeight,
|
|
126
|
+
programmaticWrites: programmaticWriteCountRef.current,
|
|
127
|
+
});
|
|
128
|
+
}, [debugDomAttributes, onDebugStateChange]);
|
|
129
|
+
|
|
130
|
+
const writeScrollTop = useCallback((nextTop: number) => {
|
|
131
|
+
const viewport = viewportRef.current;
|
|
132
|
+
if (!viewport) return;
|
|
133
|
+
viewport.scrollTop = nextTop;
|
|
134
|
+
programmaticWriteCountRef.current += 1;
|
|
135
|
+
lastProgrammaticWriteRef.current = { at: performance.now(), top: nextTop };
|
|
136
|
+
}, []);
|
|
137
|
+
|
|
138
|
+
const cancelPendingStick = useCallback(() => {
|
|
139
|
+
if (pendingStickRafRef.current !== null) {
|
|
140
|
+
cancelAnimationFrame(pendingStickRafRef.current);
|
|
141
|
+
pendingStickRafRef.current = null;
|
|
142
|
+
}
|
|
143
|
+
stickRequestedRef.current = false;
|
|
144
|
+
}, []);
|
|
145
|
+
|
|
146
|
+
const cancelReturn = useCallback(() => {
|
|
147
|
+
if (returnRafRef.current !== null) {
|
|
148
|
+
cancelAnimationFrame(returnRafRef.current);
|
|
149
|
+
returnRafRef.current = null;
|
|
150
|
+
}
|
|
151
|
+
returnAnimRef.current = null;
|
|
152
|
+
}, []);
|
|
153
|
+
|
|
154
|
+
const stickToBottomInstant = useCallback(() => {
|
|
155
|
+
const viewport = viewportRef.current;
|
|
156
|
+
if (!viewport) return;
|
|
157
|
+
|
|
158
|
+
stickRequestedRef.current = true;
|
|
159
|
+
if (pendingStickRafRef.current !== null) return;
|
|
160
|
+
|
|
161
|
+
pendingStickRafRef.current = requestAnimationFrame(() => {
|
|
162
|
+
pendingStickRafRef.current = null;
|
|
163
|
+
if (!stickRequestedRef.current) return;
|
|
164
|
+
stickRequestedRef.current = false;
|
|
165
|
+
|
|
166
|
+
if (modeRef.current !== "STICKY_INSTANT") return;
|
|
167
|
+
const m = measure(viewport);
|
|
168
|
+
writeScrollTop(m.maxScrollTop);
|
|
169
|
+
updateDebug();
|
|
170
|
+
});
|
|
171
|
+
}, [updateDebug, writeScrollTop]);
|
|
172
|
+
|
|
173
|
+
const startReturnToBottomSmooth = useCallback(() => {
|
|
174
|
+
const viewport = viewportRef.current;
|
|
175
|
+
if (!viewport) return;
|
|
176
|
+
|
|
177
|
+
cancelPendingStick();
|
|
178
|
+
cancelReturn();
|
|
179
|
+
|
|
180
|
+
const initial = measure(viewport);
|
|
181
|
+
if (initial.distanceToBottom <= EPS_BOTTOM_PX) {
|
|
182
|
+
setModeBoth("STICKY_INSTANT");
|
|
183
|
+
stickToBottomInstant();
|
|
184
|
+
updateDebug();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const durationMs = Math.max(
|
|
189
|
+
RETURN_MIN_MS,
|
|
190
|
+
Math.min(RETURN_MAX_MS, initial.distanceToBottom / RETURN_PX_PER_MS),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
returnAnimRef.current = {
|
|
194
|
+
startTime: performance.now(),
|
|
195
|
+
startTop: initial.scrollTop,
|
|
196
|
+
durationMs,
|
|
197
|
+
settleFrames: 0,
|
|
198
|
+
};
|
|
199
|
+
setModeBoth("RETURNING_SMOOTH");
|
|
200
|
+
|
|
201
|
+
const tick = (now: number) => {
|
|
202
|
+
const viewportCurrent = viewportRef.current;
|
|
203
|
+
const anim = returnAnimRef.current;
|
|
204
|
+
if (!viewportCurrent || !anim || modeRef.current !== "RETURNING_SMOOTH") {
|
|
205
|
+
cancelReturn();
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const before = measure(viewportCurrent);
|
|
210
|
+
const progress = clamp01((now - anim.startTime) / anim.durationMs);
|
|
211
|
+
const eased = easeOutCubic(progress);
|
|
212
|
+
const targetBottom = before.maxScrollTop;
|
|
213
|
+
let nextTop = anim.startTop + (targetBottom - anim.startTop) * eased;
|
|
214
|
+
|
|
215
|
+
if (nextTop < before.scrollTop) {
|
|
216
|
+
nextTop = before.scrollTop;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
writeScrollTop(nextTop);
|
|
220
|
+
|
|
221
|
+
const after = measure(viewportCurrent);
|
|
222
|
+
if (after.distanceToBottom <= EPS_BOTTOM_PX) {
|
|
223
|
+
anim.settleFrames += 1;
|
|
224
|
+
} else {
|
|
225
|
+
anim.settleFrames = 0;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const doneByTime = progress >= 1;
|
|
229
|
+
const doneBySettle = anim.settleFrames >= RETURN_SETTLE_FRAMES && progress >= 0.6;
|
|
230
|
+
if (doneByTime && doneBySettle) {
|
|
231
|
+
cancelReturn();
|
|
232
|
+
setModeBoth("STICKY_INSTANT");
|
|
233
|
+
stickToBottomInstant();
|
|
234
|
+
updateDebug();
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
updateDebug();
|
|
239
|
+
returnRafRef.current = requestAnimationFrame(tick);
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
returnRafRef.current = requestAnimationFrame(tick);
|
|
243
|
+
}, [cancelPendingStick, cancelReturn, setModeBoth, stickToBottomInstant, updateDebug, writeScrollTop]);
|
|
244
|
+
|
|
245
|
+
const onViewportScroll = useCallback(
|
|
246
|
+
(event: UIEvent<HTMLDivElement>) => {
|
|
247
|
+
const viewport = event.currentTarget;
|
|
248
|
+
const m = measure(viewport);
|
|
249
|
+
|
|
250
|
+
setIsOverflowing((previous) => (previous === m.isOverflowing ? previous : m.isOverflowing));
|
|
251
|
+
|
|
252
|
+
const previousTop = prevScrollTopRef.current;
|
|
253
|
+
const delta = m.scrollTop - previousTop;
|
|
254
|
+
|
|
255
|
+
const lastWrite = lastProgrammaticWriteRef.current;
|
|
256
|
+
const wroteRecently = lastWrite !== null && performance.now() - lastWrite.at < 60;
|
|
257
|
+
const isLikelyOwnWrite = wroteRecently && lastWrite !== null && Math.abs(m.scrollTop - lastWrite.top) <= 2;
|
|
258
|
+
|
|
259
|
+
if (modeRef.current === "RETURNING_SMOOTH") {
|
|
260
|
+
if (!isLikelyOwnWrite && delta < -DETACH_SCROLL_DELTA_PX) {
|
|
261
|
+
cancelReturn();
|
|
262
|
+
setModeBoth("DETACHED");
|
|
263
|
+
}
|
|
264
|
+
} else if (modeRef.current === "STICKY_INSTANT") {
|
|
265
|
+
if (!isLikelyOwnWrite && delta < -DETACH_SCROLL_DELTA_PX && m.isOverflowing) {
|
|
266
|
+
cancelPendingStick();
|
|
267
|
+
setModeBoth("DETACHED");
|
|
268
|
+
}
|
|
269
|
+
} else if (modeRef.current === "DETACHED") {
|
|
270
|
+
if (m.distanceToBottom <= EPS_BOTTOM_PX) {
|
|
271
|
+
setModeBoth("STICKY_INSTANT");
|
|
272
|
+
stickToBottomInstant();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
prevScrollTopRef.current = m.scrollTop;
|
|
277
|
+
updateDebug();
|
|
278
|
+
},
|
|
279
|
+
[cancelPendingStick, cancelReturn, setModeBoth, stickToBottomInstant, updateDebug],
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
useEffect(() => {
|
|
283
|
+
const viewport = viewportRef.current;
|
|
284
|
+
const content = contentRef.current;
|
|
285
|
+
if (!viewport || !content) return;
|
|
286
|
+
|
|
287
|
+
const observer = new ResizeObserver(() => {
|
|
288
|
+
const m = measure(viewport);
|
|
289
|
+
setIsOverflowing((previous) => (previous === m.isOverflowing ? previous : m.isOverflowing));
|
|
290
|
+
|
|
291
|
+
if (modeRef.current === "STICKY_INSTANT") {
|
|
292
|
+
stickToBottomInstant();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
updateDebug();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
observer.observe(content);
|
|
299
|
+
observer.observe(viewport);
|
|
300
|
+
return () => observer.disconnect();
|
|
301
|
+
}, [stickToBottomInstant, updateDebug]);
|
|
302
|
+
|
|
303
|
+
useLayoutEffect(() => {
|
|
304
|
+
const viewport = viewportRef.current;
|
|
305
|
+
if (!viewport) return;
|
|
306
|
+
|
|
307
|
+
setModeBoth("STICKY_INSTANT");
|
|
308
|
+
const initial = measure(viewport);
|
|
309
|
+
writeScrollTop(initial.maxScrollTop);
|
|
310
|
+
prevScrollTopRef.current = viewport.scrollTop;
|
|
311
|
+
setIsOverflowing(initial.isOverflowing);
|
|
312
|
+
updateDebug();
|
|
313
|
+
}, [setModeBoth, updateDebug, writeScrollTop]);
|
|
314
|
+
|
|
315
|
+
useEffect(() => {
|
|
316
|
+
return () => {
|
|
317
|
+
cancelPendingStick();
|
|
318
|
+
cancelReturn();
|
|
319
|
+
};
|
|
320
|
+
}, [cancelPendingStick, cancelReturn]);
|
|
321
|
+
|
|
322
|
+
const showJumpButton = showJumpToBottom && isOverflowing && mode === "DETACHED";
|
|
323
|
+
|
|
324
|
+
return (
|
|
325
|
+
<ScrollAreaPrimitive.Root className={cn("relative h-full w-full overflow-hidden", className)}>
|
|
326
|
+
<ScrollAreaPrimitive.Viewport
|
|
327
|
+
ref={viewportRef}
|
|
328
|
+
className="h-full w-full rounded-[inherit]"
|
|
329
|
+
onScroll={onViewportScroll}
|
|
330
|
+
data-testid="sticky-scroll-viewport"
|
|
331
|
+
>
|
|
332
|
+
<div ref={contentRef} className={cn("flex min-h-full w-full flex-col", contentClassName)}>
|
|
333
|
+
{children}
|
|
334
|
+
</div>
|
|
335
|
+
</ScrollAreaPrimitive.Viewport>
|
|
336
|
+
|
|
337
|
+
{showJumpToBottom ? (
|
|
338
|
+
<div className="pointer-events-none absolute inset-x-0 bottom-3 z-20 flex justify-center">
|
|
339
|
+
<Button
|
|
340
|
+
type="button"
|
|
341
|
+
variant="secondary"
|
|
342
|
+
onClick={startReturnToBottomSmooth}
|
|
343
|
+
data-testid="sticky-scroll-jump"
|
|
344
|
+
className={cn(
|
|
345
|
+
"z-10 h-10 w-10 rounded-full p-0 shadow-base shadow-secondary transition-all duration-150",
|
|
346
|
+
showJumpButton ? "translate-y-0 opacity-100 pointer-events-auto" : "translate-y-1 opacity-0 pointer-events-none",
|
|
347
|
+
)}
|
|
348
|
+
aria-label="Scroll to bottom"
|
|
349
|
+
>
|
|
350
|
+
<ChevronDown className="h-4 w-4 text-primary" />
|
|
351
|
+
</Button>
|
|
352
|
+
</div>
|
|
353
|
+
) : null}
|
|
354
|
+
|
|
355
|
+
<ScrollBar className={cn(showScrollBar ? "opacity-100" : "opacity-0")} orientation="vertical" />
|
|
356
|
+
</ScrollAreaPrimitive.Root>
|
|
357
|
+
);
|
|
358
|
+
}
|