poetry-core 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +3 -0
- data/DESIGN.md +145 -0
- data/LICENSE.txt +21 -0
- data/README.md +30 -0
- data/THIRD_PARTY_NOTICES.md +507 -0
- data/app/components/poetry/core/box/component.rb +82 -0
- data/app/components/poetry/core/component.rb +390 -0
- data/app/components/poetry/core/concerns/agent_tools.rb +418 -0
- data/app/components/poetry/core/concerns/declared_attributes.rb +267 -0
- data/app/components/poetry/core/concerns/introspection.rb +533 -0
- data/app/components/poetry/core/concerns/options.rb +262 -0
- data/app/components/poetry/core/concerns/parts.rb +148 -0
- data/app/components/poetry/core/concerns/stimulus.rb +363 -0
- data/app/components/poetry/core/concerns/styles.rb +323 -0
- data/app/components/poetry/core/style.rb +150 -0
- data/app/components/poetry/core/wrapper/component.rb +90 -0
- data/app/javascript/poetry/core/accordion_controller.js +165 -0
- data/app/javascript/poetry/core/action_bar_controller.js +109 -0
- data/app/javascript/poetry/core/autocomplete_controller.js +220 -0
- data/app/javascript/poetry/core/calendar_controller.js +406 -0
- data/app/javascript/poetry/core/carousel_controller.js +167 -0
- data/app/javascript/poetry/core/checkbox_group_controller.js +97 -0
- data/app/javascript/poetry/core/checked_controller.js +208 -0
- data/app/javascript/poetry/core/clipboard_text_controller.js +111 -0
- data/app/javascript/poetry/core/combobox_controller.js +1095 -0
- data/app/javascript/poetry/core/command_controller.js +510 -0
- data/app/javascript/poetry/core/context_menu_controller.js +204 -0
- data/app/javascript/poetry/core/date_field_controller.js +584 -0
- data/app/javascript/poetry/core/date_picker_controller.js +111 -0
- data/app/javascript/poetry/core/deferred_controller.js +111 -0
- data/app/javascript/poetry/core/dialog_controller.js +206 -0
- data/app/javascript/poetry/core/dismissable_controller.js +182 -0
- data/app/javascript/poetry/core/drawer_controller.js +365 -0
- data/app/javascript/poetry/core/file_input_controller.js +191 -0
- data/app/javascript/poetry/core/focus_scope_controller.js +162 -0
- data/app/javascript/poetry/core/helpers/announce.js +228 -0
- data/app/javascript/poetry/core/helpers/breakpoint.js +28 -0
- data/app/javascript/poetry/core/helpers/collection.js +18 -0
- data/app/javascript/poetry/core/helpers/direction.js +15 -0
- data/app/javascript/poetry/core/helpers/escape.js +38 -0
- data/app/javascript/poetry/core/helpers/filter_rank.js +104 -0
- data/app/javascript/poetry/core/helpers/focus_guards.js +46 -0
- data/app/javascript/poetry/core/helpers/hotkey.js +43 -0
- data/app/javascript/poetry/core/helpers/id_integrity.js +65 -0
- data/app/javascript/poetry/core/helpers/incomplete_date.js +328 -0
- data/app/javascript/poetry/core/helpers/mask.js +283 -0
- data/app/javascript/poetry/core/helpers/portal.js +217 -0
- data/app/javascript/poetry/core/helpers/presence.js +210 -0
- data/app/javascript/poetry/core/helpers/registration_guard.js +76 -0
- data/app/javascript/poetry/core/helpers/scroll_lock.js +67 -0
- data/app/javascript/poetry/core/helpers/scroller_geometry.js +422 -0
- data/app/javascript/poetry/core/helpers/state.js +80 -0
- data/app/javascript/poetry/core/helpers/tabbable.js +56 -0
- data/app/javascript/poetry/core/helpers/turbo_cache.js +21 -0
- data/app/javascript/poetry/core/helpers/typeahead.js +85 -0
- data/app/javascript/poetry/core/hotkey_controller.js +55 -0
- data/app/javascript/poetry/core/hover_card_controller.js +431 -0
- data/app/javascript/poetry/core/index.js +218 -0
- data/app/javascript/poetry/core/mask_controller.js +500 -0
- data/app/javascript/poetry/core/menu_controller.js +1050 -0
- data/app/javascript/poetry/core/menubar_controller.js +329 -0
- data/app/javascript/poetry/core/message_scroller_controller.js +930 -0
- data/app/javascript/poetry/core/navigation_menu_controller.js +458 -0
- data/app/javascript/poetry/core/number_field_controller.js +404 -0
- data/app/javascript/poetry/core/optimistic_form_controller.js +56 -0
- data/app/javascript/poetry/core/otp_controller.js +219 -0
- data/app/javascript/poetry/core/popover_controller.js +300 -0
- data/app/javascript/poetry/core/popper_controller.js +439 -0
- data/app/javascript/poetry/core/pressed_controller.js +74 -0
- data/app/javascript/poetry/core/questionnaire_controller.js +468 -0
- data/app/javascript/poetry/core/radio_group_controller.js +197 -0
- data/app/javascript/poetry/core/resizable_controller.js +202 -0
- data/app/javascript/poetry/core/roving_focus_controller.js +215 -0
- data/app/javascript/poetry/core/scroll_spy_controller.js +84 -0
- data/app/javascript/poetry/core/search_field_controller.js +95 -0
- data/app/javascript/poetry/core/select_controller.js +924 -0
- data/app/javascript/poetry/core/sensitive_input_controller.js +195 -0
- data/app/javascript/poetry/core/sheet_controller.js +24 -0
- data/app/javascript/poetry/core/sidebar_controller.js +272 -0
- data/app/javascript/poetry/core/slider_controller.js +419 -0
- data/app/javascript/poetry/core/state_controller.js +82 -0
- data/app/javascript/poetry/core/table_selection_controller.js +153 -0
- data/app/javascript/poetry/core/tabs_controller.js +151 -0
- data/app/javascript/poetry/core/tag_group_controller.js +134 -0
- data/app/javascript/poetry/core/toast_controller.js +228 -0
- data/app/javascript/poetry/core/toast_trigger_controller.js +26 -0
- data/app/javascript/poetry/core/toaster_controller.js +197 -0
- data/app/javascript/poetry/core/toggle_group_controller.js +206 -0
- data/app/javascript/poetry/core/tooltip_controller.js +541 -0
- data/app/javascript/poetry/core/tree_controller.js +265 -0
- data/app/javascript/poetry/core/vendor/floating_ui_core.js +1043 -0
- data/app/javascript/poetry/core/vendor/floating_ui_dom.js +794 -0
- data/app/javascript/poetry/core/vendor/floating_ui_utils.js +141 -0
- data/app/javascript/poetry/core/vendor/floating_ui_utils_dom.js +170 -0
- data/app/views/poetry/core/preview.html.erb +13 -0
- data/config/component_registry.yml +20 -0
- data/config/controllers_manifest.json +1528 -0
- data/config/importmap.rb +11 -0
- data/config/state_vocabulary.json +31 -0
- data/lib/active_model/type/list.rb +33 -0
- data/lib/active_model/type/symbol.rb +36 -0
- data/lib/poetry/core/check/stable_identity.rb +104 -0
- data/lib/poetry/core/check.rb +1445 -0
- data/lib/poetry/core/config.rb +286 -0
- data/lib/poetry/core/contrib/wrapped_helper.rb +68 -0
- data/lib/poetry/core/css/bem_merger.rb +45 -0
- data/lib/poetry/core/css/bem_reference.rb +87 -0
- data/lib/poetry/core/css/override_scan.rb +110 -0
- data/lib/poetry/core/css/resolver.rb +161 -0
- data/lib/poetry/core/css/safelist.rb +41 -0
- data/lib/poetry/core/css/tailwind_merger.rb +102 -0
- data/lib/poetry/core/css/template_classes.rb +102 -0
- data/lib/poetry/core/css/theme_coverage.rb +72 -0
- data/lib/poetry/core/css/var_coverage.rb +75 -0
- data/lib/poetry/core/css/verifier.rb +106 -0
- data/lib/poetry/core/design_lint.rb +720 -0
- data/lib/poetry/core/design_md/import.rb +250 -0
- data/lib/poetry/core/design_md.rb +461 -0
- data/lib/poetry/core/engine.rb +71 -0
- data/lib/poetry/core/errors.rb +53 -0
- data/lib/poetry/core/html/attributes.rb +667 -0
- data/lib/poetry/core/icons.rb +157 -0
- data/lib/poetry/core/llms_text.rb +354 -0
- data/lib/poetry/core/page_architectures.rb +304 -0
- data/lib/poetry/core/part_contract.rb +285 -0
- data/lib/poetry/core/preview/abstract.rb +121 -0
- data/lib/poetry/core/preview/base.rb +227 -0
- data/lib/poetry/core/preview/sidecarable.rb +136 -0
- data/lib/poetry/core/preview/template.rb +186 -0
- data/lib/poetry/core/recipe_items.rb +82 -0
- data/lib/poetry/core/registry.rb +363 -0
- data/lib/poetry/core/registry_address.rb +115 -0
- data/lib/poetry/core/registry_client.rb +217 -0
- data/lib/poetry/core/registry_installer.rb +234 -0
- data/lib/poetry/core/registry_items.rb +184 -0
- data/lib/poetry/core/skill_text.rb +288 -0
- data/lib/poetry/core/stable_id.rb +110 -0
- data/lib/poetry/core/stimulus/builder.rb +395 -0
- data/lib/poetry/core/stimulus/declarations.rb +444 -0
- data/lib/poetry/core/stimulus/manifest.rb +68 -0
- data/lib/poetry/core/stimulus/merger.rb +131 -0
- data/lib/poetry/core/stimulus_contract.rb +263 -0
- data/lib/poetry/core/tag_helper.rb +10 -0
- data/lib/poetry/core/template_compile.rb +74 -0
- data/lib/poetry/core/token_import.rb +317 -0
- data/lib/poetry/core/tokens/color.rb +215 -0
- data/lib/poetry/core/tokens/contrast_gate.rb +120 -0
- data/lib/poetry/core/tokens/generator.rb +211 -0
- data/lib/poetry/core/tokens.rb +117 -0
- data/lib/poetry/core/version.rb +9 -0
- data/lib/poetry/core.rb +52 -0
- data/lib/poetry-core.rb +5 -0
- data/tokens/aliases.css +145 -0
- data/tokens/tailwind-theme.css +47 -0
- data/tokens/tokens.css +78 -0
- data/tokens/tokens.dtcg.json +779 -0
- data/vendor/floating-ui/LICENSE +20 -0
- data/vendor/floating-ui/VENDORED_VERSIONS +15 -0
- data/vendor/shadcn-tailwind/LICENSE +21 -0
- data/vendor/shadcn-tailwind/VENDORED_COMMIT +1 -0
- data/vendor/shadcn-tailwind/tailwind.css +629 -0
- data/vendor/tw-animate-css/LICENSE +21 -0
- data/vendor/tw-animate-css/VENDORED_VERSION +1 -0
- data/vendor/tw-animate-css/tw-animate.css +1 -0
- metadata +267 -0
|
@@ -0,0 +1,930 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import {
|
|
3
|
+
EMPTY_MESSAGE_SCROLLER_SCROLLABLE,
|
|
4
|
+
EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE,
|
|
5
|
+
SCROLL_POSITION_EPSILON,
|
|
6
|
+
areScrollStatesEqual,
|
|
7
|
+
areVisibilityStatesEqual,
|
|
8
|
+
getContentBottom,
|
|
9
|
+
getElementScrollTop,
|
|
10
|
+
getElementTop,
|
|
11
|
+
getElementViewportTop,
|
|
12
|
+
getFirstVisibleMessageItem,
|
|
13
|
+
getFlexGap,
|
|
14
|
+
getLastScrollAnchor,
|
|
15
|
+
getMaxScrollTop,
|
|
16
|
+
getMessageScrollerItems,
|
|
17
|
+
getMessageScrollerScrollable,
|
|
18
|
+
getMessageScrollerVisibilityState,
|
|
19
|
+
getNewScrollAnchor,
|
|
20
|
+
getTailSpacerHeight,
|
|
21
|
+
getUnanchoredScrollAnchor,
|
|
22
|
+
hasMultipleNewScrollAnchors
|
|
23
|
+
} from "@poetry/controllers/helpers/scroller_geometry"
|
|
24
|
+
|
|
25
|
+
// How long (ms) data-autoscrolling stays set during a programmatic scroll
|
|
26
|
+
// before clearing. While set, the follow-bottom RELEASE is suppressed so the
|
|
27
|
+
// auto-scroll animation cannot release itself.
|
|
28
|
+
const AUTOSCROLLING_CLEAR_DELAY = 180
|
|
29
|
+
|
|
30
|
+
// Viewport keys that count as deliberate scroll intent and release follow.
|
|
31
|
+
const USER_SCROLL_KEYS = new Set([
|
|
32
|
+
"ArrowDown",
|
|
33
|
+
"ArrowUp",
|
|
34
|
+
"End",
|
|
35
|
+
"Home",
|
|
36
|
+
"PageDown",
|
|
37
|
+
"PageUp",
|
|
38
|
+
" " // Space key.
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
// The chat-transcript scroller (the centerpiece of the Gen-UI surface),
|
|
42
|
+
// adapted from an MIT-licensed source (source and license in
|
|
43
|
+
// THIRD_PARTY_NOTICES.md) - a component-tree state machine collapsed
|
|
44
|
+
// into ONE controller: refs become instance fields,
|
|
45
|
+
// useSyncExternalStore stores become data-* attributes + dispatched events
|
|
46
|
+
// (the DOM is the store), and registerMessage is DELETED: the
|
|
47
|
+
// MutationObserver + data-message-id in DOM order IS registration. Rows are
|
|
48
|
+
// content children (Turbo Streams append them; the observer never knows the
|
|
49
|
+
// difference). Geometry lives in helpers/scroller_geometry - pure, tested.
|
|
50
|
+
//
|
|
51
|
+
// Modes (mirrored to data-mode on the root, a poetry addition - a
|
|
52
|
+
// value-carrying attribute (like data-swipe-direction); the mode
|
|
53
|
+
// set is not part of the presence-pair vocabulary):
|
|
54
|
+
// following-bottom autoScroll pinned to the latest message
|
|
55
|
+
// free-scrolling reader scrolled away; position left alone
|
|
56
|
+
// anchored-to-message a turn held at the reading line while a reply streams
|
|
57
|
+
// settling-jump a programmatic jump animating; intent suppressed
|
|
58
|
+
//
|
|
59
|
+
// Viewport scroll/wheel/touchmove/keydown listeners are wired here (passive
|
|
60
|
+
// flags need addEventListener) - do NOT also declare them as data-actions.
|
|
61
|
+
// The jump button IS a data-action: click->...#jump.
|
|
62
|
+
// The component-facing event namespace (the poetry:<component> rule).
|
|
63
|
+
const EVENT_PREFIX = "poetry:message-scroller"
|
|
64
|
+
|
|
65
|
+
export default class extends Controller {
|
|
66
|
+
// The events this controller dispatches (manifest surface;
|
|
67
|
+
// events_declaration.test.js enforces the list stays honest).
|
|
68
|
+
static events = [
|
|
69
|
+
"poetry:message-scroller:mode", "poetry:message-scroller:pinned", "poetry:message-scroller:scrollable",
|
|
70
|
+
"poetry:message-scroller:unpinned", "poetry:message-scroller:visibility"
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
static targets = ["viewport", "content", "spacer", "button"]
|
|
74
|
+
static values = {
|
|
75
|
+
// Source-faithful default (the contract): the poetry ViewComponent
|
|
76
|
+
// wrapper opts INTO following by rendering the value true.
|
|
77
|
+
autoScroll: { type: Boolean, default: false },
|
|
78
|
+
defaultScrollPosition: { type: String, default: "end" }, // start | end | last-anchor
|
|
79
|
+
preserveScrollOnPrepend: { type: Boolean, default: true },
|
|
80
|
+
trackVisibility: { type: Boolean, default: false },
|
|
81
|
+
scrollEdgeThreshold: { type: Number, default: 8 },
|
|
82
|
+
scrollPreviousItemPeek: { type: Number, default: 64 },
|
|
83
|
+
scrollMargin: { type: Number, default: 0 }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Builds the ref bag, wires the viewport listeners and the three
|
|
88
|
+
* observers, runs the mount pass (rows counted, defaultScrollPosition
|
|
89
|
+
* applied once), and arms visibility tracking when opted in.
|
|
90
|
+
*/
|
|
91
|
+
connect() {
|
|
92
|
+
// The ref bag, as instance fields.
|
|
93
|
+
this.mode = this.autoScrollValue ? "following-bottom" : "free-scrolling"
|
|
94
|
+
this.autoscrolling = false
|
|
95
|
+
this.autoscrollingTimeout = null
|
|
96
|
+
this.stateFrame = null
|
|
97
|
+
this.visibilityFrame = null
|
|
98
|
+
this.itemCount = 0
|
|
99
|
+
this.firstItem = null
|
|
100
|
+
this.streamingTurn = null
|
|
101
|
+
this.prependRestore = null
|
|
102
|
+
this.pendingScrollToMessage = null
|
|
103
|
+
this.defaultScrollPositionApplied = false
|
|
104
|
+
this.spacerHeight = 0
|
|
105
|
+
this.spacerGap = getFlexGap(this.#spacer()?.parentElement ?? null)
|
|
106
|
+
this.handledScrollAnchors = new WeakSet()
|
|
107
|
+
this.visibleMessageIds = new Set()
|
|
108
|
+
this.observedRows = new Set()
|
|
109
|
+
this.visibilityObserver = null
|
|
110
|
+
this.scrollableState = EMPTY_MESSAGE_SCROLLER_SCROLLABLE
|
|
111
|
+
this.visibilityState = EMPTY_MESSAGE_SCROLLER_VISIBILITY_STATE
|
|
112
|
+
|
|
113
|
+
// Initial mirror, no transition - events fire on transitions only.
|
|
114
|
+
this.element.dataset.mode = this.mode
|
|
115
|
+
|
|
116
|
+
const viewport = this.#viewport()
|
|
117
|
+
this.onScroll = () => this.syncAfterScroll()
|
|
118
|
+
this.onWheel = () => this.userScrollIntent()
|
|
119
|
+
this.onTouchMove = () => this.userScrollIntent()
|
|
120
|
+
this.onKeydown = (event) => this.keydownIntent(event)
|
|
121
|
+
viewport.addEventListener("scroll", this.onScroll, { passive: true })
|
|
122
|
+
viewport.addEventListener("wheel", this.onWheel, { passive: true })
|
|
123
|
+
viewport.addEventListener("touchmove", this.onTouchMove, { passive: true })
|
|
124
|
+
viewport.addEventListener("keydown", this.onKeydown)
|
|
125
|
+
|
|
126
|
+
const content = this.#content()
|
|
127
|
+
this.contentObserver = null
|
|
128
|
+
this.viewportResizeObserver = null
|
|
129
|
+
this.contentResizeObserver = null
|
|
130
|
+
|
|
131
|
+
// MutationObserver on content: streamed / Turbo-Stream-appended /
|
|
132
|
+
// prepended / removed ROWS. Token growth inside an existing row is the
|
|
133
|
+
// content ResizeObserver's job, not this one's.
|
|
134
|
+
if (content && typeof MutationObserver !== "undefined") {
|
|
135
|
+
this.contentObserver = new MutationObserver(() => this.#handleContentChange())
|
|
136
|
+
this.contentObserver.observe(content, { childList: true })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
140
|
+
this.viewportResizeObserver = new ResizeObserver(() => this.#handleResize())
|
|
141
|
+
this.viewportResizeObserver.observe(viewport)
|
|
142
|
+
|
|
143
|
+
if (content) {
|
|
144
|
+
this.contentResizeObserver = new ResizeObserver(() => this.#handleResize())
|
|
145
|
+
this.contentResizeObserver.observe(content)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Mount pass: counts rows, applies
|
|
150
|
+
// defaultScrollPosition once, commits scrollable state.
|
|
151
|
+
this.#handleContentChange()
|
|
152
|
+
|
|
153
|
+
if (this.trackVisibilityValue) this.#observeVisibility()
|
|
154
|
+
|
|
155
|
+
this.started = true
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Cancels every frame/timer, disconnects the observers, and unwires the
|
|
160
|
+
* viewport listeners (the body comment holds the stale-id rule).
|
|
161
|
+
*/
|
|
162
|
+
disconnect() {
|
|
163
|
+
// Cancel and NULL every frame/timer id - a stale non-null id after a
|
|
164
|
+
// Turbo cache restore / morph reconnect makes the scheduler think a
|
|
165
|
+
// frame is still pending and never reschedule - so teardown nulls,
|
|
166
|
+
// never merely cancels.
|
|
167
|
+
this.started = false
|
|
168
|
+
|
|
169
|
+
if (this.stateFrame !== null) {
|
|
170
|
+
window.cancelAnimationFrame(this.stateFrame)
|
|
171
|
+
this.stateFrame = null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (this.visibilityFrame !== null) {
|
|
175
|
+
window.cancelAnimationFrame(this.visibilityFrame)
|
|
176
|
+
this.visibilityFrame = null
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (this.autoscrollingTimeout !== null) {
|
|
180
|
+
window.clearTimeout(this.autoscrollingTimeout)
|
|
181
|
+
this.autoscrollingTimeout = null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
this.contentObserver?.disconnect()
|
|
185
|
+
this.contentObserver = null
|
|
186
|
+
this.viewportResizeObserver?.disconnect()
|
|
187
|
+
this.viewportResizeObserver = null
|
|
188
|
+
this.contentResizeObserver?.disconnect()
|
|
189
|
+
this.contentResizeObserver = null
|
|
190
|
+
this.visibilityObserver?.disconnect()
|
|
191
|
+
this.visibilityObserver = null
|
|
192
|
+
this.observedRows.clear()
|
|
193
|
+
this.visibleMessageIds.clear()
|
|
194
|
+
|
|
195
|
+
const viewport = this.#viewport()
|
|
196
|
+
viewport.removeEventListener("scroll", this.onScroll)
|
|
197
|
+
viewport.removeEventListener("wheel", this.onWheel)
|
|
198
|
+
viewport.removeEventListener("touchmove", this.onTouchMove)
|
|
199
|
+
viewport.removeEventListener("keydown", this.onKeydown)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Stimulus value callback: a defaultScrollPosition change re-arms the
|
|
204
|
+
* one-shot apply.
|
|
205
|
+
*/
|
|
206
|
+
defaultScrollPositionValueChanged() {
|
|
207
|
+
if (!this.started) return
|
|
208
|
+
this.defaultScrollPositionApplied = false
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Stimulus value callback: re-pin if we were following.
|
|
213
|
+
*/
|
|
214
|
+
autoScrollValueChanged() {
|
|
215
|
+
if (!this.started) return
|
|
216
|
+
|
|
217
|
+
if (this.autoScrollValue && this.mode === "following-bottom" && this.itemCount > 0) {
|
|
218
|
+
this.#scrollToEnd({ behavior: "auto" })
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
this.#commitScrollState()
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// --- viewport handlers (wired in connect; public for outlet callers) ---
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The viewport's scroll handler: commits scrollable state, schedules a
|
|
229
|
+
* visibility sync, and re-captures the prepend anchor.
|
|
230
|
+
*/
|
|
231
|
+
syncAfterScroll() {
|
|
232
|
+
this.#commitScrollState()
|
|
233
|
+
this.#scheduleVisibilitySync()
|
|
234
|
+
this.#capturePrependAnchor()
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The wheel/touchmove handler: a deliberate gesture releases
|
|
239
|
+
* auto-follow, turn-anchoring, AND an in-flight programmatic jump so
|
|
240
|
+
* re-pinning never fights the reader.
|
|
241
|
+
*/
|
|
242
|
+
userScrollIntent() {
|
|
243
|
+
if (
|
|
244
|
+
this.mode === "following-bottom" ||
|
|
245
|
+
this.mode === "anchored-to-message" ||
|
|
246
|
+
this.mode === "settling-jump"
|
|
247
|
+
) {
|
|
248
|
+
this.streamingTurn = null
|
|
249
|
+
this.#setMode("free-scrolling", { reason: "user-intent" })
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The viewport's keydown handler: scroll keys count as deliberate
|
|
255
|
+
* intent.
|
|
256
|
+
*
|
|
257
|
+
* @param {KeyboardEvent} event
|
|
258
|
+
*/
|
|
259
|
+
keydownIntent(event) {
|
|
260
|
+
if (USER_SCROLL_KEYS.has(event.key)) this.userScrollIntent()
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// --- actions / commands (the useMessageScroller hook surface) ---
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The jump button's click action. No-op while inactive; blurs so focus
|
|
267
|
+
* is not stranded on a control about to inert itself.
|
|
268
|
+
*
|
|
269
|
+
* @param {MouseEvent} event
|
|
270
|
+
*/
|
|
271
|
+
jump(event) {
|
|
272
|
+
const button = event.currentTarget
|
|
273
|
+
|
|
274
|
+
if (button.dataset.active !== "true") return
|
|
275
|
+
|
|
276
|
+
button.blur()
|
|
277
|
+
|
|
278
|
+
const behavior = button.dataset.behavior === "auto" ? "auto" : "smooth"
|
|
279
|
+
|
|
280
|
+
if (button.dataset.direction === "start") this.#scrollToStart({ behavior })
|
|
281
|
+
else this.#scrollToEnd({ behavior })
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Scrolls to the end - callable as a Stimulus action (options via
|
|
286
|
+
* params) or directly with an options object (outlet callers).
|
|
287
|
+
*
|
|
288
|
+
* @param {Event | Object} [eventOrOptions] - behavior: "auto" | "smooth"
|
|
289
|
+
* @returns {boolean} whether a scroll was issued
|
|
290
|
+
*/
|
|
291
|
+
scrollToEnd(eventOrOptions = {}) {
|
|
292
|
+
return this.#scrollToEnd(this.#optionsFrom(eventOrOptions))
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Scrolls to the start (scrollToEnd's calling conventions).
|
|
297
|
+
*
|
|
298
|
+
* @param {Event | Object} [eventOrOptions]
|
|
299
|
+
* @returns {boolean} whether a scroll was issued
|
|
300
|
+
*/
|
|
301
|
+
scrollToStart(eventOrOptions = {}) {
|
|
302
|
+
return this.#scrollToStart(this.#optionsFrom(eventOrOptions))
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* scrollToMessage("id", options) programmatically, or as an action with
|
|
307
|
+
* data-...-message-id-param (align/behavior/scrollMargin params pass
|
|
308
|
+
* through).
|
|
309
|
+
*
|
|
310
|
+
* @param {Event | string} eventOrId
|
|
311
|
+
* @param {Object} [options] - align / behavior / scrollMargin
|
|
312
|
+
* @returns {boolean} true when scrolled - or queued (a miss on an empty
|
|
313
|
+
* transcript queues the request for the next content change)
|
|
314
|
+
*/
|
|
315
|
+
scrollToMessage(eventOrId, options) {
|
|
316
|
+
if (typeof eventOrId === "string") return this.#scrollToMessage(eventOrId, options)
|
|
317
|
+
|
|
318
|
+
const { messageId, id, ...rest } = this.#optionsFrom(eventOrId)
|
|
319
|
+
|
|
320
|
+
return this.#scrollToMessage(messageId ?? id, rest)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
#optionsFrom(eventOrOptions) {
|
|
324
|
+
if (!eventOrOptions) return {}
|
|
325
|
+
if (eventOrOptions.params) return eventOrOptions.params
|
|
326
|
+
if (typeof Event !== "undefined" && eventOrOptions instanceof Event) return {}
|
|
327
|
+
return eventOrOptions
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// --- elements (targets; viewport falls back to the controller root) ---
|
|
331
|
+
|
|
332
|
+
#viewport() {
|
|
333
|
+
return this.hasViewportTarget ? this.viewportTarget : this.element
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
#content() {
|
|
337
|
+
return this.hasContentTarget ? this.contentTarget : null
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
#spacer() {
|
|
341
|
+
return this.hasSpacerTarget ? this.spacerTarget : null
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// --- mode machine ---
|
|
345
|
+
|
|
346
|
+
// Mode transitions were internal-ref writes in source; poetry mirrors them
|
|
347
|
+
// to data-mode and dispatches mode / pinned / unpinned.
|
|
348
|
+
#setMode(next, { reason = "scroll-away" } = {}) {
|
|
349
|
+
const previous = this.mode
|
|
350
|
+
|
|
351
|
+
if (previous === next) return
|
|
352
|
+
|
|
353
|
+
this.mode = next
|
|
354
|
+
this.element.dataset.mode = next
|
|
355
|
+
this.dispatch("mode", { prefix: EVENT_PREFIX, detail: { from: previous, to: next, mode: next } })
|
|
356
|
+
|
|
357
|
+
if (next === "following-bottom") this.dispatch("pinned", { prefix: EVENT_PREFIX })
|
|
358
|
+
if (previous === "following-bottom") this.dispatch("unpinned", { prefix: EVENT_PREFIX, detail: { reason } })
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Owns the one follow-bottom transition: ARM at the bottom (however you got
|
|
362
|
+
// there), RELEASE on any scroll away (including a scrollbar drag) -
|
|
363
|
+
// suppressed while autoscrolling so a programmatic scroll cannot release
|
|
364
|
+
// itself, and never arming over an in-flight settling-jump.
|
|
365
|
+
#reconcileFollowMode(scrollable) {
|
|
366
|
+
if (this.autoScrollValue && !scrollable.end && this.mode !== "settling-jump") {
|
|
367
|
+
this.#setMode("following-bottom")
|
|
368
|
+
} else if (this.mode === "following-bottom" && scrollable.end && !this.autoscrolling) {
|
|
369
|
+
this.#setMode("free-scrolling", { reason: "scroll-away" })
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
#commitScrollState() {
|
|
374
|
+
const nextState = getMessageScrollerScrollable({
|
|
375
|
+
content: this.#content(),
|
|
376
|
+
scrollEdgeThreshold: this.scrollEdgeThresholdValue,
|
|
377
|
+
spacer: this.#spacer(),
|
|
378
|
+
viewport: this.#viewport()
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
this.#reconcileFollowMode(nextState)
|
|
382
|
+
this.#writeStateAttributes(nextState)
|
|
383
|
+
|
|
384
|
+
if (!areScrollStatesEqual(this.scrollableState, nextState)) {
|
|
385
|
+
this.scrollableState = nextState
|
|
386
|
+
this.dispatch("scrollable", { prefix: EVENT_PREFIX, detail: { ...nextState } })
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
#scheduleStateCommit() {
|
|
391
|
+
if (this.stateFrame !== null) return
|
|
392
|
+
|
|
393
|
+
this.stateFrame = window.requestAnimationFrame(() => {
|
|
394
|
+
this.stateFrame = null
|
|
395
|
+
this.#commitScrollState()
|
|
396
|
+
})
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
#writeStateAttributes(state) {
|
|
400
|
+
const scrollable = [state.start && "start", state.end && "end"].filter(Boolean).join(" ")
|
|
401
|
+
|
|
402
|
+
for (const element of new Set([this.element, this.#viewport()])) {
|
|
403
|
+
if (scrollable) element.setAttribute("data-scrollable", scrollable)
|
|
404
|
+
else element.removeAttribute("data-scrollable")
|
|
405
|
+
|
|
406
|
+
element.toggleAttribute("data-autoscrolling", this.autoscrolling)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// The Button part's useSyncExternalStore, collapsed: active = overflow
|
|
410
|
+
// toward the button's direction; inactive => inert + untabbable (no
|
|
411
|
+
// ghost focus stop when the reader is already caught up).
|
|
412
|
+
for (const button of this.buttonTargets) {
|
|
413
|
+
const active = button.dataset.direction === "start" ? state.start : state.end
|
|
414
|
+
|
|
415
|
+
button.dataset.active = active ? "true" : "false"
|
|
416
|
+
button.toggleAttribute("inert", !active)
|
|
417
|
+
|
|
418
|
+
if (active) button.removeAttribute("tabindex")
|
|
419
|
+
else button.setAttribute("tabindex", "-1")
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// --- visibility (the lazy visibility store, as an explicit opt-in value) ---
|
|
424
|
+
|
|
425
|
+
#scheduleVisibilitySync() {
|
|
426
|
+
if (!this.trackVisibilityValue) return
|
|
427
|
+
if (this.visibilityFrame !== null) return
|
|
428
|
+
|
|
429
|
+
this.visibilityFrame = window.requestAnimationFrame(() => {
|
|
430
|
+
this.visibilityFrame = null
|
|
431
|
+
this.#commitVisibility()
|
|
432
|
+
})
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
#commitVisibility() {
|
|
436
|
+
const nextState = getMessageScrollerVisibilityState({
|
|
437
|
+
content: this.#content(),
|
|
438
|
+
scrollMargin: this.scrollMarginValue,
|
|
439
|
+
scrollPreviousItemPeek: this.scrollPreviousItemPeekValue,
|
|
440
|
+
spacer: this.#spacer(),
|
|
441
|
+
viewport: this.#viewport(),
|
|
442
|
+
visibleMessageIds: this.visibleMessageIds
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
if (!areVisibilityStatesEqual(this.visibilityState, nextState)) {
|
|
446
|
+
this.visibilityState = nextState
|
|
447
|
+
this.dispatch("visibility", {
|
|
448
|
+
prefix: EVENT_PREFIX,
|
|
449
|
+
detail: {
|
|
450
|
+
currentAnchorId: nextState.currentAnchorId,
|
|
451
|
+
visibleMessageIds: [...nextState.visibleMessageIds]
|
|
452
|
+
}
|
|
453
|
+
})
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
#observeVisibility() {
|
|
458
|
+
if (!this.trackVisibilityValue) return
|
|
459
|
+
|
|
460
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
461
|
+
// No observer: getMessageScrollerVisibilityState falls back to rects.
|
|
462
|
+
this.#scheduleVisibilitySync()
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (!this.visibilityObserver) {
|
|
467
|
+
this.visibilityObserver = new IntersectionObserver(
|
|
468
|
+
(entries) => {
|
|
469
|
+
for (const entry of entries) {
|
|
470
|
+
const messageId = entry.target.dataset.messageId
|
|
471
|
+
|
|
472
|
+
if (!messageId) continue
|
|
473
|
+
|
|
474
|
+
if (entry.isIntersecting) this.visibleMessageIds.add(messageId)
|
|
475
|
+
else this.visibleMessageIds.delete(messageId)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
this.#scheduleVisibilitySync()
|
|
479
|
+
},
|
|
480
|
+
{
|
|
481
|
+
root: this.#viewport(),
|
|
482
|
+
// Shrink the root's top edge to the reading line so a previous turn
|
|
483
|
+
// peeking in the scrollMargin + peek band is not reported visible,
|
|
484
|
+
// keeping visibleMessageIds consistent with currentAnchorId.
|
|
485
|
+
rootMargin: `${-(this.scrollMarginValue + this.scrollPreviousItemPeekValue)}px 0px 0px 0px`,
|
|
486
|
+
threshold: [0, 0.01, 0.5, 1]
|
|
487
|
+
}
|
|
488
|
+
)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const content = this.#content()
|
|
492
|
+
|
|
493
|
+
if (content) this.#syncObservedRows(getMessageScrollerItems(content, this.#spacer()))
|
|
494
|
+
|
|
495
|
+
this.#scheduleVisibilitySync()
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// registerMessage's replacement: childList changes ARE registration.
|
|
499
|
+
#syncObservedRows(items) {
|
|
500
|
+
if (!this.visibilityObserver) return
|
|
501
|
+
|
|
502
|
+
for (const element of [...this.observedRows]) {
|
|
503
|
+
if (element.isConnected) continue
|
|
504
|
+
|
|
505
|
+
this.visibilityObserver.unobserve(element)
|
|
506
|
+
this.observedRows.delete(element)
|
|
507
|
+
|
|
508
|
+
const messageId = element.dataset.messageId
|
|
509
|
+
if (messageId) this.visibleMessageIds.delete(messageId)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
for (const element of items) {
|
|
513
|
+
if (!element.dataset.messageId || this.observedRows.has(element)) continue
|
|
514
|
+
|
|
515
|
+
this.visibilityObserver.observe(element)
|
|
516
|
+
this.observedRows.add(element)
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
this.#scheduleVisibilitySync()
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// --- content / resize handlers ---
|
|
523
|
+
|
|
524
|
+
#handleContentChange() {
|
|
525
|
+
const content = this.#content()
|
|
526
|
+
|
|
527
|
+
if (!content) return
|
|
528
|
+
|
|
529
|
+
const items = getMessageScrollerItems(content, this.#spacer())
|
|
530
|
+
const previousItemCount = this.itemCount
|
|
531
|
+
const previousFirstItem = this.firstItem
|
|
532
|
+
|
|
533
|
+
this.itemCount = items.length
|
|
534
|
+
this.firstItem = items[0] ?? null
|
|
535
|
+
|
|
536
|
+
// Every path re-captures the prepend anchor afterward, so each branch
|
|
537
|
+
// just returns. Branch order is LOAD-BEARING, ported verbatim:
|
|
538
|
+
// pending-jump, first-content, prepended, appended, updated.
|
|
539
|
+
this.#reconcileScrollPosition(items, previousItemCount, previousFirstItem)
|
|
540
|
+
this.#capturePrependAnchor()
|
|
541
|
+
this.#syncObservedRows(items)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
#reconcileScrollPosition(items, previousItemCount, previousFirstItem) {
|
|
545
|
+
if (this.#flushPendingScrollToMessage()) return
|
|
546
|
+
|
|
547
|
+
if (previousItemCount === 0) {
|
|
548
|
+
if (this.#applyDefaultScrollPosition()) return
|
|
549
|
+
|
|
550
|
+
if (items.length > 0 && this.autoScrollValue && this.#scrollToEnd({ behavior: "auto" })) {
|
|
551
|
+
return
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
this.#commitScrollState()
|
|
555
|
+
this.#scheduleVisibilitySync()
|
|
556
|
+
return
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const previousFirstItemIndex = previousFirstItem ? items.indexOf(previousFirstItem) : -1
|
|
560
|
+
const didPrepend = this.preserveScrollOnPrependValue && previousFirstItemIndex > 0
|
|
561
|
+
|
|
562
|
+
if (didPrepend) {
|
|
563
|
+
// Prepended rows are not new appends. Restore the prior scroll position
|
|
564
|
+
// (a no-op where native scroll anchoring already did it).
|
|
565
|
+
this.#restorePrependedAnchor()
|
|
566
|
+
return
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (items.length > previousItemCount) {
|
|
570
|
+
const anchor = getNewScrollAnchor(items, previousItemCount)
|
|
571
|
+
|
|
572
|
+
if (anchor) {
|
|
573
|
+
// While following the live end, a batch of several anchored turns
|
|
574
|
+
// arriving at once keeps following the end - not yanking back to
|
|
575
|
+
// anchor the first turn of the batch. A single new anchor still
|
|
576
|
+
// moves to the reading line as usual.
|
|
577
|
+
if (
|
|
578
|
+
this.autoScrollValue &&
|
|
579
|
+
this.mode === "following-bottom" &&
|
|
580
|
+
hasMultipleNewScrollAnchors(items, previousItemCount)
|
|
581
|
+
) {
|
|
582
|
+
this.#scrollToEnd({ behavior: "auto" })
|
|
583
|
+
return
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
this.#scrollToElement(anchor, { align: "start" }, { keepPreviousPeek: true })
|
|
587
|
+
this.handledScrollAnchors.add(anchor)
|
|
588
|
+
return
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (items.length === previousItemCount) {
|
|
593
|
+
// Same row count but an unhandled anchor appeared (attribute added or a
|
|
594
|
+
// morph replaced the node): anchor it once.
|
|
595
|
+
const anchor = getUnanchoredScrollAnchor(items, this.handledScrollAnchors)
|
|
596
|
+
|
|
597
|
+
if (anchor) {
|
|
598
|
+
this.#scrollToElement(anchor, { align: "start" }, { keepPreviousPeek: true })
|
|
599
|
+
this.handledScrollAnchors.add(anchor)
|
|
600
|
+
return
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Appends with no new anchor (and content-only updates) fall through:
|
|
605
|
+
// keep following the end if we still are, otherwise just recommit state.
|
|
606
|
+
if (this.mode === "following-bottom" && this.autoScrollValue) {
|
|
607
|
+
this.#scrollToEnd({ behavior: "auto" })
|
|
608
|
+
} else {
|
|
609
|
+
this.#commitScrollState()
|
|
610
|
+
this.#scheduleVisibilitySync()
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
#handleResize() {
|
|
615
|
+
if (this.mode === "following-bottom" && this.autoScrollValue) {
|
|
616
|
+
this.#scrollToEnd({ behavior: "auto" })
|
|
617
|
+
return
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Hold the anchored turn in place as content below it resizes (a reply
|
|
621
|
+
// streaming in) - otherwise the shrinking content lets the browser clamp
|
|
622
|
+
// scrollTop and the turn drops.
|
|
623
|
+
if (this.#reanchorToAnchoredMessage()) return
|
|
624
|
+
|
|
625
|
+
this.#scheduleStateCommit()
|
|
626
|
+
this.#scheduleVisibilitySync()
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
#applyDefaultScrollPosition() {
|
|
630
|
+
if (!this.defaultScrollPositionValue || this.defaultScrollPositionApplied || this.itemCount === 0) {
|
|
631
|
+
return false
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
let handled = false
|
|
635
|
+
|
|
636
|
+
if (this.defaultScrollPositionValue === "last-anchor") {
|
|
637
|
+
const content = this.#content()
|
|
638
|
+
const viewport = this.#viewport()
|
|
639
|
+
const anchor = content && viewport
|
|
640
|
+
? getLastScrollAnchor(getMessageScrollerItems(content, this.#spacer()))
|
|
641
|
+
: null
|
|
642
|
+
|
|
643
|
+
if (!content || !viewport || !anchor) {
|
|
644
|
+
handled = this.#scrollToEnd({ behavior: "auto" })
|
|
645
|
+
} else {
|
|
646
|
+
const anchorTop = getElementTop(anchor, viewport)
|
|
647
|
+
const contentBottom = getContentBottom({ content, spacer: this.#spacer(), viewport })
|
|
648
|
+
// A short last turn already fits below the anchor, so opening at the
|
|
649
|
+
// end shows the whole turn without a blank gap beneath it.
|
|
650
|
+
const lastTurnFits = contentBottom - anchorTop <= viewport.clientHeight
|
|
651
|
+
|
|
652
|
+
handled = lastTurnFits
|
|
653
|
+
? this.#scrollToEnd({ behavior: "auto" })
|
|
654
|
+
: this.#scrollToElement(anchor, { align: "start" }, { keepPreviousPeek: true })
|
|
655
|
+
}
|
|
656
|
+
} else {
|
|
657
|
+
handled = this.defaultScrollPositionValue === "end"
|
|
658
|
+
? this.#scrollToEnd({ behavior: "auto" })
|
|
659
|
+
: this.#scrollToStart({ behavior: "auto" })
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (!handled) return false
|
|
663
|
+
|
|
664
|
+
this.defaultScrollPositionApplied = true
|
|
665
|
+
|
|
666
|
+
return true
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// --- prepend preservation ---
|
|
670
|
+
|
|
671
|
+
#capturePrependAnchor() {
|
|
672
|
+
const content = this.#content()
|
|
673
|
+
const viewport = this.#viewport()
|
|
674
|
+
|
|
675
|
+
if (!content || !viewport) {
|
|
676
|
+
this.prependRestore = null
|
|
677
|
+
return
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const anchor = getFirstVisibleMessageItem({ content, spacer: this.#spacer(), viewport })
|
|
681
|
+
|
|
682
|
+
this.prependRestore = anchor
|
|
683
|
+
? { element: anchor, viewportTop: getElementViewportTop(anchor, viewport) }
|
|
684
|
+
: null
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
#restorePrependedAnchor() {
|
|
688
|
+
const anchor = this.prependRestore
|
|
689
|
+
const viewport = this.#viewport()
|
|
690
|
+
|
|
691
|
+
if (!anchor || !viewport || !anchor.element.isConnected) return false
|
|
692
|
+
|
|
693
|
+
// Compare the anchor relative to the VIEWPORT, not the content: native
|
|
694
|
+
// scroll anchoring leaves the viewport-relative position unchanged, so
|
|
695
|
+
// this is a no-op where the browser already handled the prepend and only
|
|
696
|
+
// corrects the scroll where it did not (e.g. Safari) - without trusting a
|
|
697
|
+
// capability flag, which some engines report incorrectly.
|
|
698
|
+
const nextViewportTop = getElementViewportTop(anchor.element, viewport)
|
|
699
|
+
const delta = nextViewportTop - anchor.viewportTop
|
|
700
|
+
|
|
701
|
+
if (Math.abs(delta) <= SCROLL_POSITION_EPSILON) return false
|
|
702
|
+
|
|
703
|
+
viewport.scrollTop += delta
|
|
704
|
+
anchor.viewportTop = getElementViewportTop(anchor.element, viewport)
|
|
705
|
+
this.#scheduleStateCommit()
|
|
706
|
+
this.#scheduleVisibilitySync()
|
|
707
|
+
|
|
708
|
+
return true
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// --- scroll commands (mechanics, split from the policy above as in source) ---
|
|
712
|
+
|
|
713
|
+
#setAutoScrolling(autoscrolling) {
|
|
714
|
+
if (this.autoscrollingTimeout !== null) {
|
|
715
|
+
window.clearTimeout(this.autoscrollingTimeout)
|
|
716
|
+
this.autoscrollingTimeout = null
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (this.autoscrolling !== autoscrolling) {
|
|
720
|
+
this.autoscrolling = autoscrolling
|
|
721
|
+
this.#commitScrollState()
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (autoscrolling) {
|
|
725
|
+
this.autoscrollingTimeout = window.setTimeout(() => {
|
|
726
|
+
this.autoscrollingTimeout = null
|
|
727
|
+
this.autoscrolling = false
|
|
728
|
+
this.#commitScrollState()
|
|
729
|
+
}, AUTOSCROLLING_CLEAR_DELAY)
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Tail spacer: fakes scroll room below a short anchored turn. Clamped >= 0,
|
|
734
|
+
// ceil'd; visible it gets a negative marginTop compensating the content
|
|
735
|
+
// flex gap so it adds no phantom row gap; hidden at 0.
|
|
736
|
+
#setTailSpacerHeight(height) {
|
|
737
|
+
const spacer = this.#spacer()
|
|
738
|
+
|
|
739
|
+
if (!spacer) return
|
|
740
|
+
|
|
741
|
+
const nextHeight = Math.max(0, Math.ceil(height))
|
|
742
|
+
|
|
743
|
+
if (this.spacerHeight === nextHeight) return
|
|
744
|
+
|
|
745
|
+
this.spacerHeight = nextHeight
|
|
746
|
+
spacer.hidden = nextHeight === 0
|
|
747
|
+
spacer.style.height = `${nextHeight}px`
|
|
748
|
+
spacer.style.marginTop = nextHeight > 0 ? `${-this.spacerGap}px` : ""
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// Instant scroll under prefers-reduced-motion (follow moves are already
|
|
752
|
+
// behavior: auto in source - this only downgrades requested smooth jumps).
|
|
753
|
+
#resolveBehavior(behavior) {
|
|
754
|
+
if (behavior !== "smooth") return behavior
|
|
755
|
+
|
|
756
|
+
if (
|
|
757
|
+
typeof window.matchMedia === "function" &&
|
|
758
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
759
|
+
) {
|
|
760
|
+
return "auto"
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
return behavior
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
#scrollToPosition(scrollTop, { behavior = "auto", autoscrolling = false } = {}) {
|
|
767
|
+
const viewport = this.#viewport()
|
|
768
|
+
|
|
769
|
+
if (!viewport) return
|
|
770
|
+
|
|
771
|
+
const nextScrollTop = Math.max(0, scrollTop)
|
|
772
|
+
|
|
773
|
+
// Scrolls within epsilon snap without animating (zoom/HiDPI rounding).
|
|
774
|
+
if (Math.abs(viewport.scrollTop - nextScrollTop) <= SCROLL_POSITION_EPSILON) {
|
|
775
|
+
viewport.scrollTop = nextScrollTop
|
|
776
|
+
this.#commitScrollState()
|
|
777
|
+
return
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
if (autoscrolling) this.#setAutoScrolling(true)
|
|
781
|
+
|
|
782
|
+
if (typeof viewport.scrollTo === "function") {
|
|
783
|
+
viewport.scrollTo({ top: nextScrollTop, behavior: this.#resolveBehavior(behavior) })
|
|
784
|
+
} else {
|
|
785
|
+
viewport.scrollTop = nextScrollTop
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
this.#scheduleStateCommit()
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
#scrollToStart({ behavior = "auto" } = {}) {
|
|
792
|
+
if (!this.#viewport()) return false
|
|
793
|
+
|
|
794
|
+
this.#setTailSpacerHeight(0)
|
|
795
|
+
this.streamingTurn = null
|
|
796
|
+
this.#setMode("free-scrolling", { reason: "user-intent" })
|
|
797
|
+
this.#scrollToPosition(0, { behavior })
|
|
798
|
+
this.#scheduleVisibilitySync()
|
|
799
|
+
|
|
800
|
+
return true
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
#scrollToEnd({ behavior = "auto" } = {}) {
|
|
804
|
+
const viewport = this.#viewport()
|
|
805
|
+
|
|
806
|
+
if (!viewport) return false
|
|
807
|
+
|
|
808
|
+
this.#setTailSpacerHeight(0)
|
|
809
|
+
this.streamingTurn = null
|
|
810
|
+
this.#setMode(this.autoScrollValue ? "following-bottom" : "free-scrolling", {
|
|
811
|
+
reason: "user-intent"
|
|
812
|
+
})
|
|
813
|
+
this.#scrollToPosition(getMaxScrollTop(viewport), { autoscrolling: true, behavior })
|
|
814
|
+
this.#scheduleVisibilitySync()
|
|
815
|
+
|
|
816
|
+
return true
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
#scrollToElement(element, { align = "start", behavior = "auto", scrollMargin = this.scrollMarginValue } = {}, { keepPreviousPeek = false } = {}) {
|
|
820
|
+
const content = this.#content()
|
|
821
|
+
const viewport = this.#viewport()
|
|
822
|
+
|
|
823
|
+
if (!content || !viewport || !content.contains(element)) return false
|
|
824
|
+
|
|
825
|
+
const scrollTop = getElementScrollTop({
|
|
826
|
+
align,
|
|
827
|
+
element,
|
|
828
|
+
// keepPreviousPeek adds the peek to the start margin - the reading line.
|
|
829
|
+
scrollMargin: keepPreviousPeek ? scrollMargin + this.scrollPreviousItemPeekValue : scrollMargin,
|
|
830
|
+
spacer: this.#spacer(),
|
|
831
|
+
viewport
|
|
832
|
+
})
|
|
833
|
+
|
|
834
|
+
const nextSpacerHeight = getTailSpacerHeight({
|
|
835
|
+
content,
|
|
836
|
+
scrollTop,
|
|
837
|
+
spacer: this.#spacer(),
|
|
838
|
+
viewport
|
|
839
|
+
})
|
|
840
|
+
|
|
841
|
+
this.#setTailSpacerHeight(nextSpacerHeight)
|
|
842
|
+
// Seed the prepend anchor with the jump target so a prepend landing before
|
|
843
|
+
// this scroll settles still preserves the jumped-to row; once settled,
|
|
844
|
+
// syncAfterScroll re-captures it from the first visible row.
|
|
845
|
+
this.prependRestore = {
|
|
846
|
+
element,
|
|
847
|
+
viewportTop: getElementViewportTop(element, viewport)
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
this.streamingTurn = keepPreviousPeek ? element : null
|
|
851
|
+
this.#setMode(keepPreviousPeek ? "anchored-to-message" : "settling-jump")
|
|
852
|
+
|
|
853
|
+
this.#scrollToPosition(scrollTop, { behavior })
|
|
854
|
+
this.#scheduleVisibilitySync()
|
|
855
|
+
|
|
856
|
+
return true
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// Re-run the anchored placement (recompute spacer + scrollTop) so the turn
|
|
860
|
+
// holds the reading line as content below it grows or shrinks.
|
|
861
|
+
#reanchorToAnchoredMessage() {
|
|
862
|
+
const element = this.streamingTurn
|
|
863
|
+
|
|
864
|
+
if (!element || !element.isConnected || this.mode !== "anchored-to-message") {
|
|
865
|
+
return false
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
return this.#scrollToElement(element, { align: "start" }, { keepPreviousPeek: true })
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// The target row may not be mounted yet (async transcript / lazy frame).
|
|
872
|
+
// A miss on an empty transcript queues the request, flushed on the next
|
|
873
|
+
// content change. An explicit jump marks the mount default as applied so
|
|
874
|
+
// defaultScrollPosition does not override it.
|
|
875
|
+
#scrollToMessage(messageId, options) {
|
|
876
|
+
const element = this.#findMessageElement(messageId)
|
|
877
|
+
|
|
878
|
+
if (!element) {
|
|
879
|
+
if (this.itemCount === 0) {
|
|
880
|
+
this.pendingScrollToMessage = { messageId, options }
|
|
881
|
+
this.defaultScrollPositionApplied = true
|
|
882
|
+
|
|
883
|
+
return true
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
return false
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
this.defaultScrollPositionApplied = true
|
|
890
|
+
|
|
891
|
+
if (this.#scrollToElement(element, options)) {
|
|
892
|
+
this.pendingScrollToMessage = null
|
|
893
|
+
return true
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
this.pendingScrollToMessage = { messageId, options }
|
|
897
|
+
|
|
898
|
+
return true
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
#flushPendingScrollToMessage() {
|
|
902
|
+
const pending = this.pendingScrollToMessage
|
|
903
|
+
|
|
904
|
+
if (!pending) return false
|
|
905
|
+
|
|
906
|
+
const element = this.#findMessageElement(pending.messageId)
|
|
907
|
+
|
|
908
|
+
if (!element) return false
|
|
909
|
+
|
|
910
|
+
if (!this.#scrollToElement(element, pending.options)) return false
|
|
911
|
+
|
|
912
|
+
this.pendingScrollToMessage = null
|
|
913
|
+
this.defaultScrollPositionApplied = true
|
|
914
|
+
|
|
915
|
+
return true
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// Rows register through the DOM, not a Map: look the id up in DOM order.
|
|
919
|
+
#findMessageElement(messageId) {
|
|
920
|
+
const content = this.#content()
|
|
921
|
+
|
|
922
|
+
if (!content || !messageId) return null
|
|
923
|
+
|
|
924
|
+
for (const item of getMessageScrollerItems(content, this.#spacer())) {
|
|
925
|
+
if (item.dataset.messageId === messageId) return item
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
return null
|
|
929
|
+
}
|
|
930
|
+
}
|