@adea-ai/ui 0.63.3 → 0.63.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/dist/NOTICE +31 -0
- package/dist/components/conversation/conversation-surface.d.ts +5 -1
- package/dist/components/conversation/conversation-surface.d.ts.map +1 -1
- package/dist/components/conversation/conversation-surface.js +42 -62
- package/dist/components/conversation/conversation-surface.js.map +1 -1
- package/dist/components/conversation/scroll-follow-core.d.ts +181 -0
- package/dist/components/conversation/scroll-follow-core.d.ts.map +1 -0
- package/dist/components/conversation/scroll-follow-core.js +139 -0
- package/dist/components/conversation/scroll-follow-core.js.map +1 -0
- package/dist/components/conversation/scroll-follow.d.ts +13 -0
- package/dist/components/conversation/scroll-follow.d.ts.map +1 -0
- package/dist/components/conversation/scroll-follow.js +102 -0
- package/dist/components/conversation/scroll-follow.js.map +1 -0
- package/dist/r/conversation.json +10 -0
- package/dist/r/registry.json +10 -0
- package/dist/r/src/components/conversation/conversation-surface.tsx +45 -48
- package/dist/r/src/components/conversation/scroll-follow-core.ts +314 -0
- package/dist/r/src/components/conversation/scroll-follow.ts +133 -0
- package/package.json +1 -1
- package/registry.json +10 -0
- package/src/components/conversation/conversation-surface.tsx +45 -48
- package/src/components/conversation/scroll-follow-core.ts +314 -0
- package/src/components/conversation/scroll-follow.ts +133 -0
package/README.md
CHANGED
|
@@ -153,6 +153,28 @@ The toolbar switches theme, accent, typeface and density live, which is the
|
|
|
153
153
|
fastest way to see how a component behaves across the catalogue rather than in
|
|
154
154
|
one theme.
|
|
155
155
|
|
|
156
|
+
## Transcript follow
|
|
157
|
+
|
|
158
|
+
`ConversationSurface` follows streamed text, earlier-row growth, turn collapse
|
|
159
|
+
and pane resizing using the KiroCrew plain-scroller decision contract. A
|
|
160
|
+
deliberate upward scroll releases follow, including small moves near the bottom.
|
|
161
|
+
Returning to the bottom or choosing **Jump to latest** re-arms it; the action
|
|
162
|
+
does not estimate unread messages from pixel distance. Instant pins keep
|
|
163
|
+
self-scroll attribution synchronized. Keyboard jumping returns focus to the
|
|
164
|
+
transcript when its jump control disappears; automatic pins never move focus.
|
|
165
|
+
|
|
166
|
+
`threshold` controls jump-button visibility (80px by default), independently of
|
|
167
|
+
follow intent. `follow={false}` is fully inert: positioning belongs to the host,
|
|
168
|
+
with no observer or mount pin. Re-enabling it explicitly re-arms at the bottom;
|
|
169
|
+
it is not an automatic restore-and-resume flag. An optional `resetKey` also
|
|
170
|
+
re-arms follow when that identity changes. Canonical
|
|
171
|
+
session identity, durable restoration and virtualized transcript windows remain
|
|
172
|
+
application-owned. `ref` and `onScroll` still reach the inner scroller.
|
|
173
|
+
|
|
174
|
+
`bun run test:components` checks the built component in Chromium and WebKit.
|
|
175
|
+
The `SurfaceFollowing` story exercises streaming, collapse and pane resizing;
|
|
176
|
+
source/license/test provenance is recorded in `NOTICE`.
|
|
177
|
+
|
|
156
178
|
## Composer keyboard behavior
|
|
157
179
|
|
|
158
180
|
`MessageComposer` preserves native IME candidate commits and suppresses sending
|
package/dist/NOTICE
CHANGED
|
@@ -49,6 +49,37 @@ Origin UI / coss (AGPL-3.0-or-later) was consulted for visual ideas only. No
|
|
|
49
49
|
code, class string, or asset from that project is reproduced here, because its
|
|
50
50
|
licence is incompatible with this distribution.
|
|
51
51
|
|
|
52
|
+
## Plain transcript follow translated from KiroCrew
|
|
53
|
+
|
|
54
|
+
`packages/ui/src/components/conversation/scroll-follow-core.ts`,
|
|
55
|
+
`scroll-follow.ts` and ConversationSurface substantially translate the plain
|
|
56
|
+
scroller lifecycle in `website/src/app-sdk/useChatScrollFollow.ts` and its pure
|
|
57
|
+
`website/src/hooks/virtualizer/FollowController.ts` decisions, pinned at
|
|
58
|
+
`283e136c0f902e965a535a7c9548c57c7504fed0`.
|
|
59
|
+
|
|
60
|
+
Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
61
|
+
This product includes software developed at Amazon.com, Inc.
|
|
62
|
+
(https://www.amazon.com/). Licensed under the Apache License, Version 2.0.
|
|
63
|
+
|
|
64
|
+
Retained: separate follow intent and jump visibility, synchronous scroll-up race
|
|
65
|
+
fencing, direction-aware re-engagement, self-scroll attribution, DPR tolerance,
|
|
66
|
+
content growth/collapse and viewport shrink/growth distinctions, instant pins,
|
|
67
|
+
explicit disabled/reset behavior and content/viewport observer cleanup. Solid
|
|
68
|
+
ownership replaces React refs/effects. Applications retain identity, durable
|
|
69
|
+
scroll restoration and virtualized row measurement/anchor authority.
|
|
70
|
+
|
|
71
|
+
The jump label follows `website/src/app-sdk/ChatScrollChrome.tsx`'s action-only
|
|
72
|
+
contract; pixel distance cannot establish an unread message count. The existing
|
|
73
|
+
shared button appearance and configurable 80px visibility default are retained.
|
|
74
|
+
Keyboard jumping returns focus to the focusable transcript as its jump control
|
|
75
|
+
disappears; this hardens a donor edge case without moving focus during automatic
|
|
76
|
+
pins. No donor edge-fade styling, React runtime, virtualizer, asset, font or session
|
|
77
|
+
store is copied. Tests translate applicable cases from
|
|
78
|
+
`website/src/test/useChatScrollFollow.test.tsx` and `FollowController.test.ts`;
|
|
79
|
+
Bun/browser integration and deterministic bounded grids replace React/jsdom,
|
|
80
|
+
Vitest and fast-check without adding dependencies. The full Chat product
|
|
81
|
+
composition and application adoption remain separate gates.
|
|
82
|
+
|
|
52
83
|
## Composer IME input ownership translated from KiroCrew
|
|
53
84
|
|
|
54
85
|
`packages/ui/src/components/conversation/ime-guard.ts` and its MessageComposer
|
|
@@ -10,7 +10,7 @@ import type { ComponentProps, JSX } from 'solid-js';
|
|
|
10
10
|
* to the end by an arriving message, and that is the single most common way a
|
|
11
11
|
* chat surface is made unusable.
|
|
12
12
|
* - **Offer a way back.** Once the reader is not at the bottom, a control appears
|
|
13
|
-
* that returns them
|
|
13
|
+
* that returns them. Pixel distance cannot prove an unread message count.
|
|
14
14
|
* - **`overscroll-contain`.** The transcript scrolls to its end and stops, rather
|
|
15
15
|
* than dragging the pane behind it — which in a desktop shell means the whole
|
|
16
16
|
* window moves.
|
|
@@ -18,6 +18,10 @@ import type { ComponentProps, JSX } from 'solid-js';
|
|
|
18
18
|
export type ConversationSurfaceProps = ComponentProps<'div'> & {
|
|
19
19
|
/** How far from the bottom still counts as "at the bottom", in px. */
|
|
20
20
|
threshold?: number;
|
|
21
|
+
/** Disable automatic positioning while the host owns scroll restoration. */
|
|
22
|
+
follow?: boolean;
|
|
23
|
+
/** Optional conversation identity: changing it re-arms follow at the bottom. */
|
|
24
|
+
resetKey?: string;
|
|
21
25
|
/** Rendered above the transcript when there is nothing in it. */
|
|
22
26
|
empty?: JSX.Element;
|
|
23
27
|
/** A header that scrolls with the transcript, e.g. a day divider or a banner. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"conversation-surface.d.ts","sourceRoot":"","sources":["../../../src/components/conversation/conversation-surface.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,GAAG,EAAE,MAAM,UAAU,CAAA;
|
|
1
|
+
{"version":3,"file":"conversation-surface.d.ts","sourceRoot":"","sources":["../../../src/components/conversation/conversation-surface.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,GAAG,EAAE,MAAM,UAAU,CAAA;AAMnD;;;;;;;;;;;;;;;GAeG;AACH,MAAM,MAAM,wBAAwB,GAAG,cAAc,CAAC,KAAK,CAAC,GAAG;IAC7D,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,gFAAgF;IAChF,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,iEAAiE;IACjE,KAAK,CAAC,EAAE,GAAG,CAAC,OAAO,CAAA;IACnB,iFAAiF;IACjF,MAAM,CAAC,EAAE,GAAG,CAAC,OAAO,CAAA;CACrB,CAAA;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,wBAAwB,eAmElE"}
|
|
@@ -1,63 +1,50 @@
|
|
|
1
1
|
import { cn } from "../../lib/utils.js";
|
|
2
2
|
import { Button } from "../ui/button/button.js";
|
|
3
|
-
import {
|
|
3
|
+
import { createScrollFollow } from "./scroll-follow.js";
|
|
4
|
+
import { className, createComponent, effect, insert, mergeProps, spread, template, use } from "solid-js/web";
|
|
4
5
|
import { ArrowDown } from "lucide-solid";
|
|
5
|
-
import { Show,
|
|
6
|
+
import { Show, splitProps } from "solid-js";
|
|
6
7
|
//#region src/components/conversation/conversation-surface.tsx
|
|
7
8
|
var _tmpl$ = /*#__PURE__*/ template(`<div class="pointer-events-none absolute inset-x-0 bottom-3 flex justify-center">`);
|
|
8
|
-
var _tmpl$2 = /*#__PURE__*/ template(`<div data-slot=conversation-surface><div class="min-h-0 flex-1 overflow-y-auto overscroll-contain">`);
|
|
9
|
+
var _tmpl$2 = /*#__PURE__*/ template(`<div data-slot=conversation-surface><div class="min-h-0 flex-1 overflow-y-auto overscroll-contain"><div data-slot=conversation-content>`);
|
|
9
10
|
var _tmpl$3 = /*#__PURE__*/ template(`<div class=p-4>`);
|
|
10
|
-
/** How far the reader is from the end of the transcript, in px. */
|
|
11
|
-
function distanceFromBottom(element) {
|
|
12
|
-
return element.scrollHeight - element.scrollTop - element.clientHeight;
|
|
13
|
-
}
|
|
14
11
|
function ConversationSurface(props) {
|
|
15
12
|
const [local, rest] = splitProps(props, [
|
|
16
13
|
"class",
|
|
17
14
|
"threshold",
|
|
15
|
+
"follow",
|
|
16
|
+
"resetKey",
|
|
18
17
|
"empty",
|
|
19
18
|
"header",
|
|
20
|
-
"children"
|
|
19
|
+
"children",
|
|
20
|
+
"onScroll",
|
|
21
|
+
"ref",
|
|
22
|
+
"tabindex"
|
|
21
23
|
]);
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const jumpToLatest = () => {
|
|
33
|
-
scroller?.scrollTo({
|
|
34
|
-
top: scroller.scrollHeight,
|
|
35
|
-
behavior: "smooth"
|
|
36
|
-
});
|
|
37
|
-
setAtBottom(true);
|
|
38
|
-
setMissed(0);
|
|
39
|
-
};
|
|
40
|
-
const observer = () => {
|
|
41
|
-
if (!scroller) return;
|
|
42
|
-
const mutation = new MutationObserver(() => {
|
|
43
|
-
if (atBottom()) scroller?.scrollTo({ top: scroller.scrollHeight });
|
|
44
|
-
else onScroll();
|
|
45
|
-
});
|
|
46
|
-
mutation.observe(scroller, {
|
|
47
|
-
childList: true,
|
|
48
|
-
subtree: true
|
|
49
|
-
});
|
|
50
|
-
onCleanup(() => mutation.disconnect());
|
|
24
|
+
const follow = createScrollFollow({
|
|
25
|
+
enabled: () => local.follow !== false,
|
|
26
|
+
resetKey: () => local.resetKey,
|
|
27
|
+
threshold: () => local.threshold ?? 80
|
|
28
|
+
});
|
|
29
|
+
const onScroll = (event) => {
|
|
30
|
+
follow.onScroll();
|
|
31
|
+
const handler = local.onScroll;
|
|
32
|
+
if (typeof handler === "function") handler(event);
|
|
33
|
+
else if (handler) handler[0](handler[1], event);
|
|
51
34
|
};
|
|
52
35
|
return (() => {
|
|
53
|
-
var _el$ = _tmpl$2(), _el$2 = _el$.firstChild;
|
|
36
|
+
var _el$ = _tmpl$2(), _el$2 = _el$.firstChild, _el$3 = _el$2.firstChild;
|
|
54
37
|
_el$2.addEventListener("scroll", onScroll);
|
|
55
38
|
use((element) => {
|
|
56
|
-
|
|
57
|
-
|
|
39
|
+
follow.bindScroller(element);
|
|
40
|
+
if (typeof local.ref === "function") local.ref(element);
|
|
58
41
|
}, _el$2);
|
|
59
|
-
spread(_el$2,
|
|
60
|
-
|
|
42
|
+
spread(_el$2, mergeProps({ get tabindex() {
|
|
43
|
+
return local.tabindex ?? 0;
|
|
44
|
+
} }, rest), false, true);
|
|
45
|
+
var _ref$ = follow.bindContent;
|
|
46
|
+
typeof _ref$ === "function" ? use(_ref$, _el$3) : follow.bindContent = _el$3;
|
|
47
|
+
insert(_el$3, createComponent(Show, {
|
|
61
48
|
get when() {
|
|
62
49
|
return local.header;
|
|
63
50
|
},
|
|
@@ -65,15 +52,15 @@ function ConversationSurface(props) {
|
|
|
65
52
|
return local.header;
|
|
66
53
|
}
|
|
67
54
|
}), null);
|
|
68
|
-
insert(_el$
|
|
55
|
+
insert(_el$3, createComponent(Show, {
|
|
69
56
|
get when() {
|
|
70
57
|
return local.children;
|
|
71
58
|
},
|
|
72
59
|
get fallback() {
|
|
73
60
|
return (() => {
|
|
74
|
-
var _el$
|
|
75
|
-
insert(_el$
|
|
76
|
-
return _el$
|
|
61
|
+
var _el$5 = _tmpl$3();
|
|
62
|
+
insert(_el$5, () => local.empty);
|
|
63
|
+
return _el$5;
|
|
77
64
|
})();
|
|
78
65
|
},
|
|
79
66
|
get children() {
|
|
@@ -82,30 +69,23 @@ function ConversationSurface(props) {
|
|
|
82
69
|
}), null);
|
|
83
70
|
insert(_el$, createComponent(Show, {
|
|
84
71
|
get when() {
|
|
85
|
-
return !atBottom();
|
|
72
|
+
return !follow.atBottom();
|
|
86
73
|
},
|
|
87
74
|
get children() {
|
|
88
|
-
var _el$
|
|
89
|
-
insert(_el$
|
|
75
|
+
var _el$4 = _tmpl$();
|
|
76
|
+
insert(_el$4, createComponent(Button, {
|
|
77
|
+
type: "button",
|
|
90
78
|
size: "sm",
|
|
91
79
|
variant: "secondary",
|
|
92
80
|
"class": "pointer-events-auto gap-1.5 shadow-md",
|
|
93
|
-
onClick
|
|
81
|
+
get onClick() {
|
|
82
|
+
return follow.jump;
|
|
83
|
+
},
|
|
94
84
|
get children() {
|
|
95
|
-
return [createComponent(ArrowDown, {}),
|
|
96
|
-
get when() {
|
|
97
|
-
return missed() > 0;
|
|
98
|
-
},
|
|
99
|
-
get fallback() {
|
|
100
|
-
return "Jump to latest";
|
|
101
|
-
},
|
|
102
|
-
get children() {
|
|
103
|
-
return [memo(() => missed()), " new"];
|
|
104
|
-
}
|
|
105
|
-
})];
|
|
85
|
+
return [createComponent(ArrowDown, {}), "Jump to latest"];
|
|
106
86
|
}
|
|
107
87
|
}));
|
|
108
|
-
return _el$
|
|
88
|
+
return _el$4;
|
|
109
89
|
}
|
|
110
90
|
}), null);
|
|
111
91
|
effect(() => className(_el$, cn("relative flex min-h-0 flex-1 flex-col", local.class)));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"conversation-surface.js","names":["ArrowDown","ComponentProps","JSX","Show","
|
|
1
|
+
{"version":3,"file":"conversation-surface.js","names":["ArrowDown","ComponentProps","JSX","Show","splitProps","cn","Button","createScrollFollow","ConversationSurfaceProps","threshold","follow","resetKey","empty","Element","header","ConversationSurface","props","local","rest","enabled","onScroll","EventHandlerUnion","HTMLDivElement","Event","event","handler","_el$","_tmpl$2","_el$2","firstChild","_el$3","addEventListener","_$use","element","bindScroller","ref","_$spread","_$mergeProps","tabindex","_ref$","bindContent","_$insert","_$createComponent","when","children","fallback","_el$5","_tmpl$3","atBottom","_el$4","_tmpl$","type","size","variant","onClick","jump","_$effect","_$className","class","_$mergeProps","_$use","_$createComponent","_$className"],"sources":["../../../src/components/conversation/conversation-surface.tsx"],"sourcesContent":["import { ArrowDown } from 'lucide-solid'\nimport type { ComponentProps, JSX } from 'solid-js'\nimport { Show, splitProps } from 'solid-js'\nimport { cn } from '#lib/utils'\nimport { Button } from '../ui/button/button'\nimport { createScrollFollow } from './scroll-follow'\n\n/**\n * ConversationSurface.\n *\n * The scrolling transcript. Its job is small and it is the difference between a\n * transcript that stays readable as it grows and one that fights the reader:\n *\n * - **Stick to the bottom.** New messages scroll into view only when the reader is\n * already at the bottom. Someone reading back through history must not be yanked\n * to the end by an arriving message, and that is the single most common way a\n * chat surface is made unusable.\n * - **Offer a way back.** Once the reader is not at the bottom, a control appears\n * that returns them. Pixel distance cannot prove an unread message count.\n * - **`overscroll-contain`.** The transcript scrolls to its end and stops, rather\n * than dragging the pane behind it — which in a desktop shell means the whole\n * window moves.\n */\nexport type ConversationSurfaceProps = ComponentProps<'div'> & {\n /** How far from the bottom still counts as \"at the bottom\", in px. */\n threshold?: number\n /** Disable automatic positioning while the host owns scroll restoration. */\n follow?: boolean\n /** Optional conversation identity: changing it re-arms follow at the bottom. */\n resetKey?: string\n /** Rendered above the transcript when there is nothing in it. */\n empty?: JSX.Element\n /** A header that scrolls with the transcript, e.g. a day divider or a banner. */\n header?: JSX.Element\n}\n\nexport function ConversationSurface(props: ConversationSurfaceProps) {\n const [local, rest] = splitProps(props, [\n 'class',\n 'threshold',\n 'follow',\n 'resetKey',\n 'empty',\n 'header',\n 'children',\n 'onScroll',\n 'ref',\n 'tabindex',\n ])\n // Kobalte provides the control's semantics; transcript follow is a native\n // geometry contract. KiroCrew's plain-scroller controller handles streaming,\n // content collapse and pane resizing without treating them as user consent.\n const follow = createScrollFollow({\n enabled: () => local.follow !== false,\n resetKey: () => local.resetKey,\n threshold: () => local.threshold ?? 80,\n })\n const onScroll: JSX.EventHandlerUnion<HTMLDivElement, Event> = (event) => {\n follow.onScroll()\n const handler = local.onScroll\n if (typeof handler === 'function') handler(event)\n else if (handler) handler[0](handler[1], event)\n }\n\n return (\n <div\n data-slot=\"conversation-surface\"\n class={cn('relative flex min-h-0 flex-1 flex-col', local.class)}\n >\n <div\n ref={(element) => {\n follow.bindScroller(element)\n if (typeof local.ref === 'function') local.ref(element)\n }}\n onScroll={onScroll}\n tabindex={local.tabindex ?? 0}\n class=\"min-h-0 flex-1 overflow-y-auto overscroll-contain\"\n {...rest}\n >\n <div ref={follow.bindContent} data-slot=\"conversation-content\">\n <Show when={local.header}>{local.header}</Show>\n <Show when={local.children} fallback={<div class=\"p-4\">{local.empty}</div>}>\n {local.children}\n </Show>\n </div>\n </div>\n\n <Show when={!follow.atBottom()}>\n <div class=\"pointer-events-none absolute inset-x-0 bottom-3 flex justify-center\">\n <Button\n type=\"button\"\n size=\"sm\"\n variant=\"secondary\"\n class=\"pointer-events-auto gap-1.5 shadow-md\"\n onClick={follow.jump}\n >\n <ArrowDown />\n Jump to latest\n </Button>\n </div>\n </Show>\n </div>\n )\n}\n"],"mappings":";;;;;;;;;;AAoCA,SAAgBe,oBAAoBC,OAAiC;CACnE,MAAM,CAACC,OAAOC,QAAQd,WAAWY,OAAO;EACtC;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;CAAU,CACX;CAID,MAAMN,SAASH,mBAAmB;EAChCY,eAAeF,MAAMP,WAAW;EAChCC,gBAAgBM,MAAMN;EACtBF,iBAAiBQ,MAAMR,aAAa;CACtC,CAAC;CACD,MAAMW,YAA0DI,UAAU;EACxEd,OAAOU,SAAS;EAChB,MAAMK,UAAUR,MAAMG;EACtB,IAAI,OAAOK,YAAY,YAAYA,QAAQD,KAAK;OAC3C,IAAIC,SAASA,QAAQ,EAAE,CAACA,QAAQ,IAAID,KAAK;CAChD;CAEA,cAAA;EAAA,IAAAE,OAAAC,QAAA,GAAAC,QAAAF,KAAAG,YAAAC,QAAAF,MAAAC;EAAAD,MAAAG,iBAAA,UAUgBX,QAAQ;EAAAY,KAJZC,YAAY;GAChBvB,OAAOwB,aAAaD,OAAO;GAC3B,IAAI,OAAOhB,MAAMkB,QAAQ,YAAYlB,MAAMkB,IAAIF,OAAO;EACxD,GAACL,KAAA;EAAAQ,OAAAR,OAAAS,WAAA,EAAA,IAEDC,WAAQ;GAAA,OAAErB,MAAMqB,YAAY;EAAC,EAAA,GAEzBpB,IAAI,GAAA,OAAA,IAAA;EAAA,IAAAqB,QAEE7B,OAAO8B;EAAW,OAAAD,UAAA,aAAAP,IAAAO,OAAAT,KAAA,IAAlBpB,OAAO8B,cAAWV;EAAAW,OAAAX,OAAAY,gBACzBvC,MAAI;GAAA,IAACwC,OAAI;IAAA,OAAE1B,MAAMH;GAAM;GAAA,IAAA8B,WAAA;IAAA,OAAG3B,MAAMH;GAAM;EAAA,CAAA,GAAA,IAAA;EAAA2B,OAAAX,OAAAY,gBACtCvC,MAAI;GAAA,IAACwC,OAAI;IAAA,OAAE1B,MAAM2B;GAAQ;GAAA,IAAEC,WAAQ;IAAA,cAAA;KAAA,IAAAC,QAAAC,QAAA;KAAAN,OAAAK,aAAoB7B,MAAML,KAAK;KAAA,OAAAkC;IAAA,EAAA,CAAA;GAAA;GAAA,IAAAF,WAAA;IAAA,OAChE3B,MAAM2B;GAAQ;EAAA,CAAA,GAAA,IAAA;EAAAH,OAAAf,MAAAgB,gBAKpBvC,MAAI;GAAA,IAACwC,OAAI;IAAA,OAAE,CAACjC,OAAOsC,SAAS;GAAC;GAAA,IAAAJ,WAAA;IAAA,IAAAK,QAAAC,OAAA;IAAAT,OAAAQ,OAAAP,gBAEzBpC,QAAM;KACL6C,MAAI;KACJC,MAAI;KACJC,SAAO;KAAA,SAAA;KAAA,IAEPC,UAAO;MAAA,OAAE5C,OAAO6C;KAAI;KAAA,IAAAX,WAAA;MAAA,OAAA,CAAAF,gBAEnB1C,WAAS,CAAA,CAAA,GAAA,gBAAA;KAAA;IAAA,CAAA,CAAA;IAAA,OAAAiD;GAAA;EAAA,CAAA,GAAA,IAAA;EAAAO,aAAAC,UAAA/B,MA7BTrB,GAAG,yCAAyCY,MAAMyC,KAAK,CAAC,CAAA;EAAA,OAAAhC;CAAA,EAAA,CAAA;AAoCrE"}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/** Default distance (px) from the bottom within which `isAtBottom` is true. */
|
|
2
|
+
export declare const DEFAULT_BOTTOM_THRESHOLD = 100;
|
|
3
|
+
/**
|
|
4
|
+
* Tolerance (px) for treating a scroll position as "the same" as a value we
|
|
5
|
+
* wrote programmatically. Covers sub-pixel rounding and 1px momentum overshoot.
|
|
6
|
+
* Must stay small so a deliberate user scroll of even a few px is still seen as
|
|
7
|
+
* a user scroll.
|
|
8
|
+
*/
|
|
9
|
+
export declare const SELF_SCROLL_EPSILON = 2;
|
|
10
|
+
/**
|
|
11
|
+
* "At the bottom" tolerance (px) for deciding whether an auto-pin still has
|
|
12
|
+
* work to do. A flat 0.5 is UNDER one device pixel at fractional device-pixel
|
|
13
|
+
* ratios (0.67 CSS px at 150% zoom, 0.8 at 125%): the scroller's resting
|
|
14
|
+
* maximum scrollTop lands on a fractional value, so `|scrollTop - target|`
|
|
15
|
+
* stays just above 0.5 even when the viewport is visually pinned to the
|
|
16
|
+
* bottom — making the pin re-fire on every ResizeObserver tick. Scaling the
|
|
17
|
+
* epsilon to the device pixel (never below 1 CSS px) absorbs that fractional
|
|
18
|
+
* resting error. `devicePixelRatio` is read defensively so a jsdom / SSR
|
|
19
|
+
* environment that leaves it undefined falls back to 1 (→ 1.5px).
|
|
20
|
+
*/
|
|
21
|
+
export declare function atBottomEpsilon(): number;
|
|
22
|
+
/** Live scroll geometry snapshot read from the scroller element. */
|
|
23
|
+
export interface ScrollGeom {
|
|
24
|
+
scrollTop: number;
|
|
25
|
+
scrollHeight: number;
|
|
26
|
+
clientHeight: number;
|
|
27
|
+
}
|
|
28
|
+
/** scrollTop that places the viewport exactly at the bottom (never negative). */
|
|
29
|
+
export declare function bottomTarget(geom: ScrollGeom): number;
|
|
30
|
+
/** Pixels between the current scroll position and the bottom. */
|
|
31
|
+
export declare function distanceFromBottom(geom: ScrollGeom): number;
|
|
32
|
+
/** Whether the scroller is within `threshold` px of the bottom. */
|
|
33
|
+
export declare function computeAtBottom(geom: ScrollGeom, threshold: number): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Recognise a `scroll` event caused by our own programmatic write rather than
|
|
36
|
+
* by the user. `lastWriteTop < 0` means "we have not written this session", so
|
|
37
|
+
* any scroll is treated as the user's.
|
|
38
|
+
*/
|
|
39
|
+
export declare function isSelfScroll(scrollTop: number, lastWriteTop: number, epsilon?: number): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Distance (px) from the true bottom within which a user scroll RE-ENGAGES
|
|
42
|
+
* follow. Deliberately much tighter than DEFAULT_BOTTOM_THRESHOLD: that 100px
|
|
43
|
+
* band drives the jump-to-bottom pill's visibility, and reusing it for follow
|
|
44
|
+
* meant a deliberate 3-99px scroll-up kept `stick` armed — the next content
|
|
45
|
+
* change then yanked the reader back to the bottom. Re-engaging only when the
|
|
46
|
+
* user has returned essentially to the bottom keeps "scrolled up to read"
|
|
47
|
+
* positions belonging to the user.
|
|
48
|
+
*/
|
|
49
|
+
export declare const FOLLOW_REENGAGE_PX = 16;
|
|
50
|
+
/**
|
|
51
|
+
* Direction-aware `stick` decision for a *user-initiated* scroll (self-scrolls
|
|
52
|
+
* filtered out by the caller via `isSelfScroll`):
|
|
53
|
+
*
|
|
54
|
+
* 1. At the true bottom (within the DPR-aware epsilon) → follow. This also
|
|
55
|
+
* absorbs the layout engine's clamp: a mid-stream content SHRINK drops
|
|
56
|
+
* scrollTop (which reads as an upward move) but lands exactly at the new
|
|
57
|
+
* bottom — releasing there froze streaming follow for the rest of the
|
|
58
|
+
* turn.
|
|
59
|
+
* 2. Any other upward move → release, regardless of distance from the
|
|
60
|
+
* bottom. The scroll position now belongs to the user; only returning to
|
|
61
|
+
* the bottom (3) re-engages.
|
|
62
|
+
* 3. A genuine DOWNWARD move that arrives within FOLLOW_REENGAGE_PX of the
|
|
63
|
+
* bottom → re-engage. A neutral event inside the band does NOT: that is
|
|
64
|
+
* how content collapsing under a still reader re-armed follow.
|
|
65
|
+
* 4. Otherwise (downward/neutral, still away from the bottom) → keep the
|
|
66
|
+
* previous state.
|
|
67
|
+
*
|
|
68
|
+
* `prevScrollTop < 0` means "no prior observation this session". Direction is
|
|
69
|
+
* unknowable then, so the decision is position-only and CONSERVATIVE: follow
|
|
70
|
+
* only within the re-engage band. Keeping a stale `stick` on an unattributable
|
|
71
|
+
* away-from-bottom scroll is how a reader gets yanked.
|
|
72
|
+
*/
|
|
73
|
+
export declare function resolveUserScrollStick(args: {
|
|
74
|
+
stick: boolean;
|
|
75
|
+
followOutput: boolean;
|
|
76
|
+
scrollTop: number;
|
|
77
|
+
prevScrollTop: number;
|
|
78
|
+
geom: ScrollGeom;
|
|
79
|
+
/** Change in the scroller's own height since the previous scroll event.
|
|
80
|
+
*
|
|
81
|
+
* Positive = the viewport GREW (the composer shrank under a deletion, the
|
|
82
|
+
* keyboard closed). That growth lowers the maximum scrollTop, so the engine
|
|
83
|
+
* clamps any reader parked closer to the bottom than the growth — with no
|
|
84
|
+
* application write anywhere. The clamp then arrives here as an ordinary
|
|
85
|
+
* scroll event sitting at distance ~0, which rule 1 below used to read as
|
|
86
|
+
* "the reader came back to the bottom" and re-arm follow for someone who
|
|
87
|
+
* never touched the scroller. The next turn to start then took them to the
|
|
88
|
+
* end. Rule 1 exists to absorb a CONTENT-shrink clamp mid-stream, and content
|
|
89
|
+
* shrink moves `scrollHeight`, not `clientHeight` — so the two are
|
|
90
|
+
* distinguishable, and this is the delta that tells them apart. */
|
|
91
|
+
viewportGrowth?: number;
|
|
92
|
+
}): boolean;
|
|
93
|
+
/** Result of an automatic (RO / append) pin evaluation. */
|
|
94
|
+
export interface AutoPinResult {
|
|
95
|
+
/** Whether to write `el.scrollTop = target` now. */
|
|
96
|
+
pin: boolean;
|
|
97
|
+
/** Next value for `stick` (released to false if the user scrolled up). */
|
|
98
|
+
stick: boolean;
|
|
99
|
+
/** The bottom scrollTop the caller should write when `pin` is true. */
|
|
100
|
+
target: number;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Decide an automatic pin at the moment content changed (RO callback / append
|
|
104
|
+
* layout effect / its follow-up rAF), reading LIVE geometry.
|
|
105
|
+
*
|
|
106
|
+
* - Not sticking → never pin.
|
|
107
|
+
* - Sticking but the user has scrolled up since our last write
|
|
108
|
+
* (`scrollTop < lastWriteTop - epsilon`) → release stick, don't pin.
|
|
109
|
+
* This is the synchronous, race-proof guard.
|
|
110
|
+
* - Otherwise → pin to the bottom (only actually move if not already there).
|
|
111
|
+
*
|
|
112
|
+
* `lastWriteTop < 0` disables the scroll-up guard (used right after a slot
|
|
113
|
+
* switch, before we have written anything this session).
|
|
114
|
+
*
|
|
115
|
+
* `viewportShrink` (px, default 0) is how much the SCROLLER'S OWN BOX has
|
|
116
|
+
* shrunk since that reference was recorded — chrome mounting below the
|
|
117
|
+
* transcript (a queue band, an attachment strip, a tip card), often
|
|
118
|
+
* spring-animated over several frames. Our own shrink inflates
|
|
119
|
+
* `distanceFromBottom` with no user input, so without this allowance the
|
|
120
|
+
* distance guard reads it as "meaningfully away from the bottom". Paired with
|
|
121
|
+
* a content SHRINK in the same commit window — a tail-row remount clamping
|
|
122
|
+
* scrollTop below `lastWriteTop` — that produced a full user-scroll-up
|
|
123
|
+
* signature out of two of our own layout changes: follow released mid
|
|
124
|
+
* animation and the content settled a card-height low. Judging the distance
|
|
125
|
+
* against the box we were last a bottom FOR keeps the guard measuring the
|
|
126
|
+
* user's move rather than our own. Only the shrink's own pixels are forgiven,
|
|
127
|
+
* so a genuine drag inside the same tick still releases.
|
|
128
|
+
*/
|
|
129
|
+
export declare function evaluateAutoPin(args: {
|
|
130
|
+
stick: boolean;
|
|
131
|
+
geom: ScrollGeom;
|
|
132
|
+
lastWriteTop: number;
|
|
133
|
+
epsilon?: number;
|
|
134
|
+
viewportShrink?: number;
|
|
135
|
+
/** Is a turn actually producing output right now?
|
|
136
|
+
*
|
|
137
|
+
* Follow means "keep me at the end of a LIVE turn". With nothing running there
|
|
138
|
+
* is no output to follow, so a reader sitting above the bottom is not
|
|
139
|
+
* following — and an automatic pin there is a yank with no cause, reported
|
|
140
|
+
* from a phone as the transcript springing back after scrolling up about a
|
|
141
|
+
* hundred pixels with nothing streaming.
|
|
142
|
+
*
|
|
143
|
+
* Defaults to `true` = assume a run is live, which keeps the behaviour of a
|
|
144
|
+
* caller that has no run signal to give (the app-SDK chat surface). The chat
|
|
145
|
+
* transcript passes the real thing. */
|
|
146
|
+
runActive?: boolean;
|
|
147
|
+
/** Is an anchor restore currently OWNING the scroll position?
|
|
148
|
+
*
|
|
149
|
+
* A restore places the reader at an absolute offset and then re-lands it as
|
|
150
|
+
* measurements arrive. An automatic pin during that window is a second owner
|
|
151
|
+
* writing the same scroller, and the two fight: captured on a phone as
|
|
152
|
+
* `WRITE autopin 3091->4245` answered by `WRITE settle 4245->3091`, twice in
|
|
153
|
+
* 120ms, 1,154px each way. The settle won those rounds, but only because its
|
|
154
|
+
* budget had not expired yet -- which is why the same switch sometimes landed
|
|
155
|
+
* at the bottom and sometimes did not.
|
|
156
|
+
*
|
|
157
|
+
* Released rather than merely skipped, for the reason the idle branch below
|
|
158
|
+
* gives: skipping leaves follow armed, so the next growth yanks the reader
|
|
159
|
+
* from wherever the restore just put them. */
|
|
160
|
+
restoreGate?: boolean;
|
|
161
|
+
/** Has hardware input -- wheel / touch / pointer / a scrolling key -- reached
|
|
162
|
+
* the scroller since we last placed the reader at the bottom?
|
|
163
|
+
*
|
|
164
|
+
* False means the reader has done nothing, so a gap that opened while they
|
|
165
|
+
* rest on our last write was opened by content -- a row settling from its
|
|
166
|
+
* estimate, a code-block stand-in swapping for the highlighted block, the
|
|
167
|
+
* top spacer repricing -- and is a gap WE owe them, not one they chose.
|
|
168
|
+
*
|
|
169
|
+
* The idle rule below cannot tell those apart from distance alone, and it
|
|
170
|
+
* errs toward release, which was invisible wherever the browser's native
|
|
171
|
+
* scroll anchoring quietly carried the reader through the growth. WebKit has
|
|
172
|
+
* no scroll anchoring at all, so on an iPhone every entry into an idle
|
|
173
|
+
* session paid the whole post-pin reprice as a displacement and then had
|
|
174
|
+
* follow released on top of it: the transcript opened a viewport or more
|
|
175
|
+
* above the end with nothing streaming to bring it back.
|
|
176
|
+
*
|
|
177
|
+
* Defaults to `true` = assume the reader may have moved, which is the
|
|
178
|
+
* release-leaning legacy behaviour for a caller that has no input signal. */
|
|
179
|
+
readerMovedSinceWrite?: boolean;
|
|
180
|
+
}): AutoPinResult;
|
|
181
|
+
//# sourceMappingURL=scroll-follow-core.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scroll-follow-core.d.ts","sourceRoot":"","sources":["../../../src/components/conversation/scroll-follow-core.ts"],"names":[],"mappings":"AAqBA,+EAA+E;AAC/E,eAAO,MAAM,wBAAwB,MAAM,CAAA;AAE3C;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,IAAI,CAAA;AAEpC;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,IAAI,MAAM,CAQxC;AAED,oEAAoE;AACpE,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAA;IACjB,YAAY,EAAE,MAAM,CAAA;IACpB,YAAY,EAAE,MAAM,CAAA;CACrB;AAED,iFAAiF;AACjF,wBAAgB,YAAY,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM,CAErD;AAED,iEAAiE;AACjE,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM,CAE3D;AAED,mEAAmE;AACnE,wBAAgB,eAAe,CAAC,IAAI,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAE5E;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAC1B,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,MAAM,EACpB,OAAO,GAAE,MAA4B,GACpC,OAAO,CAET;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,KAAK,CAAA;AAEpC;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE;IAC3C,KAAK,EAAE,OAAO,CAAA;IACd,YAAY,EAAE,OAAO,CAAA;IACrB,SAAS,EAAE,MAAM,CAAA;IACjB,aAAa,EAAE,MAAM,CAAA;IACrB,IAAI,EAAE,UAAU,CAAA;IAChB;;;;;;;;;;;wEAWoE;IACpE,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB,GAAG,OAAO,CA4BV;AAED,2DAA2D;AAC3D,MAAM,WAAW,aAAa;IAC5B,oDAAoD;IACpD,GAAG,EAAE,OAAO,CAAA;IACZ,0EAA0E;IAC1E,KAAK,EAAE,OAAO,CAAA;IACd,uEAAuE;IACvE,MAAM,EAAE,MAAM,CAAA;CACf;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE;IACpC,KAAK,EAAE,OAAO,CAAA;IACd,IAAI,EAAE,UAAU,CAAA;IAChB,YAAY,EAAE,MAAM,CAAA;IACpB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB;;;;;;;;;;4CAUwC;IACxC,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB;;;;;;;;;;;;mDAY+C;IAC/C,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB;;;;;;;;;;;;;;;;;kFAiB8E;IAC9E,qBAAqB,CAAC,EAAE,OAAO,CAAA;CAChC,GAAG,aAAa,CAsDhB"}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "At the bottom" tolerance (px) for deciding whether an auto-pin still has
|
|
3
|
+
* work to do. A flat 0.5 is UNDER one device pixel at fractional device-pixel
|
|
4
|
+
* ratios (0.67 CSS px at 150% zoom, 0.8 at 125%): the scroller's resting
|
|
5
|
+
* maximum scrollTop lands on a fractional value, so `|scrollTop - target|`
|
|
6
|
+
* stays just above 0.5 even when the viewport is visually pinned to the
|
|
7
|
+
* bottom — making the pin re-fire on every ResizeObserver tick. Scaling the
|
|
8
|
+
* epsilon to the device pixel (never below 1 CSS px) absorbs that fractional
|
|
9
|
+
* resting error. `devicePixelRatio` is read defensively so a jsdom / SSR
|
|
10
|
+
* environment that leaves it undefined falls back to 1 (→ 1.5px).
|
|
11
|
+
*/
|
|
12
|
+
function atBottomEpsilon() {
|
|
13
|
+
const dpr = typeof window !== "undefined" && typeof window.devicePixelRatio === "number" && window.devicePixelRatio > 0 ? window.devicePixelRatio : 1;
|
|
14
|
+
return Math.max(1, 1 / dpr + .5);
|
|
15
|
+
}
|
|
16
|
+
/** scrollTop that places the viewport exactly at the bottom (never negative). */
|
|
17
|
+
function bottomTarget(geom) {
|
|
18
|
+
return Math.max(0, geom.scrollHeight - geom.clientHeight);
|
|
19
|
+
}
|
|
20
|
+
/** Pixels between the current scroll position and the bottom. */
|
|
21
|
+
function distanceFromBottom(geom) {
|
|
22
|
+
return geom.scrollHeight - geom.scrollTop - geom.clientHeight;
|
|
23
|
+
}
|
|
24
|
+
/** Whether the scroller is within `threshold` px of the bottom. */
|
|
25
|
+
function computeAtBottom(geom, threshold) {
|
|
26
|
+
return distanceFromBottom(geom) <= threshold;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Recognise a `scroll` event caused by our own programmatic write rather than
|
|
30
|
+
* by the user. `lastWriteTop < 0` means "we have not written this session", so
|
|
31
|
+
* any scroll is treated as the user's.
|
|
32
|
+
*/
|
|
33
|
+
function isSelfScroll(scrollTop, lastWriteTop, epsilon = 2) {
|
|
34
|
+
return lastWriteTop >= 0 && Math.abs(scrollTop - lastWriteTop) <= epsilon;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Direction-aware `stick` decision for a *user-initiated* scroll (self-scrolls
|
|
38
|
+
* filtered out by the caller via `isSelfScroll`):
|
|
39
|
+
*
|
|
40
|
+
* 1. At the true bottom (within the DPR-aware epsilon) → follow. This also
|
|
41
|
+
* absorbs the layout engine's clamp: a mid-stream content SHRINK drops
|
|
42
|
+
* scrollTop (which reads as an upward move) but lands exactly at the new
|
|
43
|
+
* bottom — releasing there froze streaming follow for the rest of the
|
|
44
|
+
* turn.
|
|
45
|
+
* 2. Any other upward move → release, regardless of distance from the
|
|
46
|
+
* bottom. The scroll position now belongs to the user; only returning to
|
|
47
|
+
* the bottom (3) re-engages.
|
|
48
|
+
* 3. A genuine DOWNWARD move that arrives within FOLLOW_REENGAGE_PX of the
|
|
49
|
+
* bottom → re-engage. A neutral event inside the band does NOT: that is
|
|
50
|
+
* how content collapsing under a still reader re-armed follow.
|
|
51
|
+
* 4. Otherwise (downward/neutral, still away from the bottom) → keep the
|
|
52
|
+
* previous state.
|
|
53
|
+
*
|
|
54
|
+
* `prevScrollTop < 0` means "no prior observation this session". Direction is
|
|
55
|
+
* unknowable then, so the decision is position-only and CONSERVATIVE: follow
|
|
56
|
+
* only within the re-engage band. Keeping a stale `stick` on an unattributable
|
|
57
|
+
* away-from-bottom scroll is how a reader gets yanked.
|
|
58
|
+
*/
|
|
59
|
+
function resolveUserScrollStick(args) {
|
|
60
|
+
const { stick, followOutput, scrollTop, prevScrollTop, geom } = args;
|
|
61
|
+
if (!followOutput) return false;
|
|
62
|
+
const dist = distanceFromBottom(geom);
|
|
63
|
+
const clampedByViewport = (args.viewportGrowth ?? 0) > atBottomEpsilon() && scrollTop <= prevScrollTop + atBottomEpsilon();
|
|
64
|
+
if (dist <= atBottomEpsilon()) return clampedByViewport ? stick : true;
|
|
65
|
+
if (prevScrollTop < 0) return dist <= 16;
|
|
66
|
+
if (scrollTop < prevScrollTop - .5) return false;
|
|
67
|
+
if (scrollTop > prevScrollTop + .5 && dist <= 16) return true;
|
|
68
|
+
return stick;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Decide an automatic pin at the moment content changed (RO callback / append
|
|
72
|
+
* layout effect / its follow-up rAF), reading LIVE geometry.
|
|
73
|
+
*
|
|
74
|
+
* - Not sticking → never pin.
|
|
75
|
+
* - Sticking but the user has scrolled up since our last write
|
|
76
|
+
* (`scrollTop < lastWriteTop - epsilon`) → release stick, don't pin.
|
|
77
|
+
* This is the synchronous, race-proof guard.
|
|
78
|
+
* - Otherwise → pin to the bottom (only actually move if not already there).
|
|
79
|
+
*
|
|
80
|
+
* `lastWriteTop < 0` disables the scroll-up guard (used right after a slot
|
|
81
|
+
* switch, before we have written anything this session).
|
|
82
|
+
*
|
|
83
|
+
* `viewportShrink` (px, default 0) is how much the SCROLLER'S OWN BOX has
|
|
84
|
+
* shrunk since that reference was recorded — chrome mounting below the
|
|
85
|
+
* transcript (a queue band, an attachment strip, a tip card), often
|
|
86
|
+
* spring-animated over several frames. Our own shrink inflates
|
|
87
|
+
* `distanceFromBottom` with no user input, so without this allowance the
|
|
88
|
+
* distance guard reads it as "meaningfully away from the bottom". Paired with
|
|
89
|
+
* a content SHRINK in the same commit window — a tail-row remount clamping
|
|
90
|
+
* scrollTop below `lastWriteTop` — that produced a full user-scroll-up
|
|
91
|
+
* signature out of two of our own layout changes: follow released mid
|
|
92
|
+
* animation and the content settled a card-height low. Judging the distance
|
|
93
|
+
* against the box we were last a bottom FOR keeps the guard measuring the
|
|
94
|
+
* user's move rather than our own. Only the shrink's own pixels are forgiven,
|
|
95
|
+
* so a genuine drag inside the same tick still releases.
|
|
96
|
+
*/
|
|
97
|
+
function evaluateAutoPin(args) {
|
|
98
|
+
const { stick, geom, lastWriteTop } = args;
|
|
99
|
+
const epsilon = args.epsilon ?? 2;
|
|
100
|
+
const viewportShrink = Math.max(0, args.viewportShrink ?? 0);
|
|
101
|
+
const runActive = args.runActive ?? true;
|
|
102
|
+
const readerMovedSinceWrite = args.readerMovedSinceWrite ?? true;
|
|
103
|
+
const target = bottomTarget(geom);
|
|
104
|
+
if (args.restoreGate) return {
|
|
105
|
+
pin: false,
|
|
106
|
+
stick: false,
|
|
107
|
+
target
|
|
108
|
+
};
|
|
109
|
+
if (!stick) return {
|
|
110
|
+
pin: false,
|
|
111
|
+
stick: false,
|
|
112
|
+
target
|
|
113
|
+
};
|
|
114
|
+
const restingOnOurWrite = lastWriteTop >= 0 && Math.abs(geom.scrollTop - lastWriteTop) <= epsilon;
|
|
115
|
+
if (!readerMovedSinceWrite && restingOnOurWrite) return {
|
|
116
|
+
pin: distanceFromBottom(geom) > atBottomEpsilon(),
|
|
117
|
+
stick: true,
|
|
118
|
+
target
|
|
119
|
+
};
|
|
120
|
+
if (!runActive && distanceFromBottom(geom) > atBottomEpsilon()) return {
|
|
121
|
+
pin: false,
|
|
122
|
+
stick: false,
|
|
123
|
+
target
|
|
124
|
+
};
|
|
125
|
+
if (lastWriteTop >= 0 && geom.scrollTop < lastWriteTop - epsilon && distanceFromBottom(geom) - viewportShrink > epsilon) return {
|
|
126
|
+
pin: false,
|
|
127
|
+
stick: false,
|
|
128
|
+
target
|
|
129
|
+
};
|
|
130
|
+
return {
|
|
131
|
+
pin: Math.abs(geom.scrollTop - target) > atBottomEpsilon(),
|
|
132
|
+
stick: true,
|
|
133
|
+
target
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
//#endregion
|
|
137
|
+
export { atBottomEpsilon, bottomTarget, computeAtBottom, distanceFromBottom, evaluateAutoPin, isSelfScroll, resolveUserScrollStick };
|
|
138
|
+
|
|
139
|
+
//# sourceMappingURL=scroll-follow-core.js.map
|