@cardstack/boxel-cli 0.11.0-unstable.1 → 0.11.0-unstable.3
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/bundled-realms/pretui/components/agent-mark.gts +98 -0
- package/bundled-realms/pretui/components/agent-question.gts +340 -37
- package/bundled-realms/pretui/components/answer-footer.gts +249 -0
- package/bundled-realms/pretui/components/card-embed.gts +523 -0
- package/bundled-realms/pretui/components/card-pills.gts +725 -0
- package/bundled-realms/pretui/components/code-block.gts +87 -45
- package/bundled-realms/pretui/components/collapsed-turn.gts +133 -12
- package/bundled-realms/pretui/components/compaction-chip.gts +81 -67
- package/bundled-realms/pretui/components/composer-cap.gts +516 -0
- package/bundled-realms/pretui/components/context-ring.gts +108 -0
- package/bundled-realms/pretui/components/diff-block.gts +1002 -31
- package/bundled-realms/pretui/components/diff-stat.gts +102 -0
- package/bundled-realms/pretui/components/jump-chip.gts +86 -53
- package/bundled-realms/pretui/components/markdown-stream.gts +799 -0
- package/bundled-realms/pretui/components/markdown-surface.gts +71 -0
- package/bundled-realms/pretui/components/plan-mark.gts +147 -0
- package/bundled-realms/pretui/components/prose.gts +185 -8
- package/bundled-realms/pretui/components/prose.md +6 -8
- package/bundled-realms/pretui/components/quick-look.gts +377 -0
- package/bundled-realms/pretui/components/reactions.gts +136 -0
- package/bundled-realms/pretui/components/recommendation.gts +67 -36
- package/bundled-realms/pretui/components/result-card.gts +58 -22
- package/bundled-realms/pretui/components/segmented-control.gts +13 -8
- package/bundled-realms/pretui/components/session-prep.gts +12 -11
- package/bundled-realms/pretui/components/sliding-highlight.gts +8 -3
- package/bundled-realms/pretui/components/speaker-label.gts +132 -0
- package/bundled-realms/pretui/components/state-glyph.gts +121 -0
- package/bundled-realms/pretui/components/step-list.gts +62 -60
- package/bundled-realms/pretui/components/stick-to-bottom.gts +1150 -0
- package/bundled-realms/pretui/components/system-line.gts +143 -0
- package/bundled-realms/pretui/components/task-checklist.gts +184 -0
- package/bundled-realms/pretui/components/thinking.gts +63 -94
- package/bundled-realms/pretui/components/tool-change-table.gts +285 -0
- package/bundled-realms/pretui/components/tool-gate.gts +462 -0
- package/bundled-realms/pretui/components/tool-group.gts +920 -0
- package/bundled-realms/pretui/components/tool-icon.ts +64 -0
- package/bundled-realms/pretui/components/tool-meta.gts +82 -0
- package/bundled-realms/pretui/components/tool-plan.gts +197 -0
- package/bundled-realms/pretui/components/tool-row.gts +2087 -0
- package/bundled-realms/pretui/components/turn-changes.gts +227 -0
- package/bundled-realms/pretui/components/user-bubble.gts +575 -0
- package/bundled-realms/pretui/components/work-item.gts +185 -48
- package/bundled-realms/pretui/docs/scroll-anchoring-protocol.md +136 -0
- package/bundled-realms/pretui/internal/markdown-projection.ts +438 -0
- package/bundled-realms/pretui/internal/mount-queue.ts +79 -0
- package/bundled-realms/pretui/internal/tool-phrases.ts +168 -0
- package/bundled-realms/pretui/package.json +2 -1
- package/bundled-test-harness/assets/{ai-module-creation-test-beRNwH-e.js → ai-module-creation-test-B6FQKqIl.js} +1 -1
- package/bundled-test-harness/assets/{app-QAuw0l8v.js → app-KpPqdyX3.js} +4 -4
- package/bundled-test-harness/assets/{binary-upload-dedupe-test-bI8T0QLl.js → binary-upload-dedupe-test-DN00y1tM.js} +1 -1
- package/bundled-test-harness/assets/{can-invoke-test-DVaU_yRT.js → can-invoke-test-Bl-EUFM3.js} +1 -1
- package/bundled-test-harness/assets/{card-chooser-paging-test-DnxMnXKR.js → card-chooser-paging-test-BNuZRHX3.js} +1 -1
- package/bundled-test-harness/assets/{card-chooser-test-DUSnLr-u.js → card-chooser-test-wKfPOOT4.js} +1 -1
- package/bundled-test-harness/assets/{card-def-field-def-relationships-test-GBUr3tFK.js → card-def-field-def-relationships-test-CduQ6zan.js} +1 -1
- package/bundled-test-harness/assets/{card-delete-test-CQveO1VC.js → card-delete-test-DKxolj5o.js} +1 -1
- package/bundled-test-harness/assets/{check-correctness-test-D2M2p66N.js → check-correctness-test-B-kgHztp.js} +1 -1
- package/bundled-test-harness/assets/choreo-BEHW-NOc.js +2 -0
- package/bundled-test-harness/assets/{choreo-CJfBb_EY.js → choreo-C2cpCfhA.js} +164 -13
- package/bundled-test-harness/assets/{choreo-feature-reel-test-D2OrygZv.js → choreo-feature-reel-test-CPI_6nkT.js} +2 -2
- package/bundled-test-harness/assets/{choreo-gallery-w07xqtO-.js → choreo-gallery-BxA9cyzF.js} +3 -3
- package/bundled-test-harness/assets/{choreo-gallery-build-order-test-yQg4JER6.js → choreo-gallery-build-order-test-CLVoseAJ.js} +1 -1
- package/bundled-test-harness/assets/{choreo-gallery-compositor-test-C2THCXJN.js → choreo-gallery-compositor-test-BpT5Ia6B.js} +1 -1
- package/bundled-test-harness/assets/{choreo-gallery-cover-test-D4T_Tk4s.js → choreo-gallery-cover-test-DL_fdSsh.js} +1 -1
- package/bundled-test-harness/assets/{choreo-gallery-crossing-stage-test-tHUPJ-Hy.js → choreo-gallery-crossing-stage-test-BcKHhpOK.js} +2 -2
- package/bundled-test-harness/assets/{choreo-gallery-demos-alive-test-BOtBOnEZ.js → choreo-gallery-demos-alive-test-KufLhXG8.js} +1 -1
- package/bundled-test-harness/assets/{choreo-gallery-drift-test-D_6U1Z2c.js → choreo-gallery-drift-test-Cx58rYIA.js} +1 -1
- package/bundled-test-harness/assets/{choreo-gallery-escort-test-DDBU0n4C.js → choreo-gallery-escort-test-Wz-emmID.js} +1 -1
- package/bundled-test-harness/assets/{choreo-gallery-films-test-CgW1NSdr.js → choreo-gallery-films-test-BFrVs5WW.js} +2 -2
- package/bundled-test-harness/assets/{choreo-gallery-filter-test-DUIsNwdO.js → choreo-gallery-filter-test-DCKQkhwe.js} +1 -1
- package/bundled-test-harness/assets/{choreo-gallery-fold-test-Chzlxvdr.js → choreo-gallery-fold-test-UtpDgpG9.js} +1 -1
- package/bundled-test-harness/assets/{choreo-gallery-guide-pages-test-DOWnqALT.js → choreo-gallery-guide-pages-test-rXDU9JLM.js} +1 -1
- package/bundled-test-harness/assets/{choreo-gallery-guides-test-Dra2J4LJ.js → choreo-gallery-guides-test-DfVFdIuU.js} +3 -3
- package/bundled-test-harness/assets/{choreo-gallery-hang-test-zzn1eiB-.js → choreo-gallery-hang-test-Cj7MbkFf.js} +1 -1
- package/bundled-test-harness/assets/{choreo-gallery-highlight-test-CW99hncQ.js → choreo-gallery-highlight-test-D6CF3IYZ.js} +2 -2
- package/bundled-test-harness/assets/{choreo-gallery-inline-edit-test-CYFm1kUz.js → choreo-gallery-inline-edit-test-LfHWsbqc.js} +1 -1
- package/bundled-test-harness/assets/{choreo-gallery-interruption-test-CYIBrKV9.js → choreo-gallery-interruption-test-CvQQAgTp.js} +2 -2
- package/bundled-test-harness/assets/{choreo-gallery-lone-stack-test-DL8R3SG7.js → choreo-gallery-lone-stack-test-D7Zci7fm.js} +1 -1
- package/bundled-test-harness/assets/{choreo-gallery-navigation-test-Dcv04kDx.js → choreo-gallery-navigation-test-DB1EnBEW.js} +1 -1
- package/bundled-test-harness/assets/{choreo-gallery-playhead-transport-test-Cfz7GsXl.js → choreo-gallery-playhead-transport-test-CtBlc6AJ.js} +1 -1
- package/bundled-test-harness/assets/{choreo-gallery-poplayout-subtree-test-CwoklBkW.js → choreo-gallery-poplayout-subtree-test-BQbmjsvI.js} +1 -1
- package/bundled-test-harness/assets/{choreo-gallery-presence-modes-test-BDCIZzJ0.js → choreo-gallery-presence-modes-test-CIdXVG1K.js} +1 -1
- package/bundled-test-harness/assets/{choreo-gallery-presentation-test-DlyiHwFf.js → choreo-gallery-presentation-test-C_rPFp29.js} +1 -1
- package/bundled-test-harness/assets/{choreo-gallery-reduced-motion-test-DVz5SKNR.js → choreo-gallery-reduced-motion-test-CH5w8Lw-.js} +2 -2
- package/bundled-test-harness/assets/{choreo-gallery-sheet-standalone-test-CpD_-BvS.js → choreo-gallery-sheet-standalone-test-CDgI4mGX.js} +1 -1
- package/bundled-test-harness/assets/{choreo-gallery-sheet-test-BjvAKHDL.js → choreo-gallery-sheet-test-5lsFX7Rk.js} +1 -1
- package/bundled-test-harness/assets/{choreo-gallery-site-test-oq_7y_PN.js → choreo-gallery-site-test-DbMa2xeC.js} +2 -2
- package/bundled-test-harness/assets/{choreo-gallery-stage-Bzr4_8Xd.js → choreo-gallery-stage-b3Sk86hp.js} +2 -2
- package/bundled-test-harness/assets/{choreo-gallery-stage-keyboard-test-a83d2euJ.js → choreo-gallery-stage-keyboard-test-ds6fT5Eg.js} +1 -1
- package/bundled-test-harness/assets/{choreo-gallery-stages-test-C9Sl1kH6.js → choreo-gallery-stages-test-ahioKCct.js} +2 -2
- package/bundled-test-harness/assets/{choreo-gallery-subdivision-test-BVVnkOwk.js → choreo-gallery-subdivision-test-g7anTDhT.js} +1 -1
- package/bundled-test-harness/assets/{choreo-gallery-table-plan-test-B0YdYEA0.js → choreo-gallery-table-plan-test-Bozc4uUf.js} +1 -1
- package/bundled-test-harness/assets/{choreo-gallery-theater-test-BLA147xw.js → choreo-gallery-theater-test-kE535d-b.js} +3 -3
- package/bundled-test-harness/assets/{choreo-gallery-tuning-test-DSXiNzCh.js → choreo-gallery-tuning-test-6rHkQBnZ.js} +1 -1
- package/bundled-test-harness/assets/{choreo-gallery-widget-room-test-BI574PdO.js → choreo-gallery-widget-room-test-0Vq-uTo1.js} +2 -2
- package/bundled-test-harness/assets/{choreo-gallery-wires-test-B6Mz2WZl.js → choreo-gallery-wires-test-CdtnAWYO.js} +1 -1
- package/bundled-test-harness/assets/clip-BLfuHi8g.js +2 -0
- package/bundled-test-harness/assets/{clip-C_pXjVvY.js → clip-q1QE5f73.js} +1 -1
- package/bundled-test-harness/assets/{codeblocks-test-AdolhdCA.js → codeblocks-test-BDD7qK_V.js} +1 -1
- package/bundled-test-harness/assets/{compaction-test-DvyAQq56.js → compaction-test-BweolkhG.js} +1 -1
- package/bundled-test-harness/assets/{container-DIF_9lZ9.css → container-B5B6u-xm.css} +29 -22
- package/bundled-test-harness/assets/{container-DvvqmNpg.js → container-BnJjIM6J.js} +9 -6
- package/bundled-test-harness/assets/{create-listing-modal-test-D95JfQab.js → create-listing-modal-test-FvcAKpIa.js} +1 -1
- package/bundled-test-harness/assets/{debug-message-test-BcNtj7Wr.js → debug-message-test-CJ18Evt7.js} +1 -1
- package/bundled-test-harness/assets/{externals-6ZWftYIb.js → externals-BWjhmCD4.js} +12 -12
- package/bundled-test-harness/assets/{fallback-banner-live-delete-test-5lnN1pv2.js → fallback-banner-live-delete-test-C2H3BrT-.js} +1 -1
- package/bundled-test-harness/assets/{fallback-banner-test-DFb_m_Kq.js → fallback-banner-test-C10aYjkj.js} +1 -1
- package/bundled-test-harness/assets/{fallback-models-test-p3YIwj1L.js → fallback-models-test-BxoPiaGF.js} +1 -1
- package/bundled-test-harness/assets/{file-attachment-test-CmyjNWRR.js → file-attachment-test-DeKyzYaz.js} +1 -1
- package/bundled-test-harness/assets/film-B68PjRlr.js +2 -0
- package/bundled-test-harness/assets/{film-D7vtJO5q.js → film-CeUpnHN_.js} +6 -6
- package/bundled-test-harness/assets/{film-DN0A8JHx.js → film-DBZqyqW4.js} +6 -6
- package/bundled-test-harness/assets/{general-test-CX-TFDuX.js → general-test-BewzrpoB.js} +1 -1
- package/bundled-test-harness/assets/{glimmer-motion-and-choreo-in-cards-test-BHte2t9G.js → glimmer-motion-and-choreo-in-cards-test-HA-INN3d.js} +14 -14
- package/bundled-test-harness/assets/{glimmer-motion-and-choreo-test-BPNVeIio.js → glimmer-motion-and-choreo-test-BZ0ZuuAw.js} +3 -3
- package/bundled-test-harness/assets/{index-query-engine-test-Dz7Q4Xmk.js → index-query-engine-test-BtI8Y2LR.js} +1 -1
- package/bundled-test-harness/assets/{instance-filter-matcher-test-BoLU3bVg.js → instance-filter-matcher-test-BHXHGHjE.js} +1 -1
- package/bundled-test-harness/assets/{main-Dlgp8c7v.js → main-CqAHZ2kK.js} +1 -1
- package/bundled-test-harness/assets/{message-tool-test-CfjRSJvt.js → message-tool-test-NMaPJFsd.js} +1 -1
- package/bundled-test-harness/assets/{model-cost-tier-test-mZG84Uso.js → model-cost-tier-test-BgbfN2LH.js} +1 -1
- package/bundled-test-harness/assets/{operation-lowering-test-B0KpI1xu.js → operation-lowering-test-C4IaOjsA.js} +1 -1
- package/bundled-test-harness/assets/{operator-mode-card-chooser-test-KWS8lq16.js → operator-mode-card-chooser-test-Bwe47Rn7.js} +1 -1
- package/bundled-test-harness/assets/{operator-mode-links-test-CViFt0Ws.js → operator-mode-links-test-BU4jkC-B.js} +1 -1
- package/bundled-test-harness/assets/{operator-mode-test-D0gZM2yw.js → operator-mode-test-Bh0rGM9z.js} +1 -1
- package/bundled-test-harness/assets/{operator-mode-ui-test-CEvHJ9DS.js → operator-mode-ui-test-whgUw0ju.js} +1 -1
- package/bundled-test-harness/assets/{overlay-menu-items-test-Pw80RXsr.js → overlay-menu-items-test-Cwn1vr8q.js} +1 -1
- package/bundled-test-harness/assets/{overlays-BnOM360u.js → overlays-BOzLbVDK.js} +1 -1
- package/bundled-test-harness/assets/{overlays-kBVfa4i8.js → overlays-C_PuGDGh.js} +1 -1
- package/bundled-test-harness/assets/{past-sessions-test-CW0-Bjav.js → past-sessions-test-Bh24N-Kz.js} +1 -1
- package/bundled-test-harness/assets/{permission-checking-realm-test-D5uXQypS.js → permission-checking-realm-test-DKnnAy0f.js} +1 -1
- package/bundled-test-harness/assets/{plate-CFvCDv2t.js → plate-BL7UtyKE.js} +1 -1
- package/bundled-test-harness/assets/plate-CqG3igCJ.js +2 -0
- package/bundled-test-harness/assets/player-BJfsgdSI.js +2 -0
- package/bundled-test-harness/assets/{player-DzuYjrTU.js → player-Da_CIsp_.js} +1 -1
- package/bundled-test-harness/assets/{realm-config-policy-standing-test-Btatheni.js → realm-config-policy-standing-test-DngdYypq.js} +1 -1
- package/bundled-test-harness/assets/{realm-config-routing-rule-edit-test-Dh5zW13o.js → realm-config-routing-rule-edit-test-_rkFU8SN.js} +1 -1
- package/bundled-test-harness/assets/{realm-config-settings-test-BJa8gv_C.js → realm-config-settings-test-CR9NH0Zn.js} +1 -1
- package/bundled-test-harness/assets/{reasoning-test-2Zp37cQB.js → reasoning-test-0lH10kRl.js} +1 -1
- package/bundled-test-harness/assets/{room-message-test-BsrON13e.js → room-message-test-C-PddE4Y.js} +1 -1
- package/bundled-test-harness/assets/{run-BMyvn_UL.js → run-D4n4LPcK.js} +96 -6
- package/bundled-test-harness/assets/{run-realm-code-BxktQf6R.js → run-realm-code-C8N7DtN-.js} +1 -1
- package/bundled-test-harness/assets/{run-realm-code-test-7grpTos-.js → run-realm-code-test-DBdp147q.js} +1 -1
- package/bundled-test-harness/assets/{schema-generation-test-CM9ggQji.js → schema-generation-test-CB1Hb7GQ.js} +1 -1
- package/bundled-test-harness/assets/{scrolling-test-BER00htB.js → scrolling-test-3PhVin8h.js} +1 -1
- package/bundled-test-harness/assets/{searchable-option-test-BBr5BPow.js → searchable-option-test-BAdOiL4U.js} +1 -1
- package/bundled-test-harness/assets/{sending-test-BJGobs2E.js → sending-test-KSmu1tSJ.js} +1 -1
- package/bundled-test-harness/assets/{skills-test-GPOsHRN6.js → skills-test-D-dlcFpd.js} +1 -1
- package/bundled-test-harness/assets/{src-Df148w_b.js → src-B_DEhPze.js} +4 -4
- package/bundled-test-harness/assets/{src-Cf2wv-Le.js → src-BoTgwqUA.js} +2 -2
- package/bundled-test-harness/assets/{store-test-QvvHimzH.js → store-test-CEW7xQjZ.js} +1 -1
- package/bundled-test-harness/assets/{test-support-rNHuieEc.js → test-support-FXDPpIl3.js} +1 -1
- package/bundled-test-harness/assets/{test-support-C6N_2X-l.js → test-support-VX6ukQNR.js} +1 -1
- package/bundled-test-harness/assets/{tests-CmsQ-Z_3.js → tests-QYqqa6nv.js} +84 -84
- package/bundled-test-harness/assets/{text-input-validator-test-CYYH2N0L.js → text-input-validator-test-D8iNOWep.js} +1 -1
- package/bundled-test-harness/assets/{titles-B1pTaIfP.js → titles-DFJj5O0E.js} +1 -1
- package/bundled-test-harness/assets/titles-xGlgu7ys.js +2 -0
- package/bundled-test-harness/assets/{to-device-streaming-test-Ctqa0bVp.js → to-device-streaming-test-trCX-505.js} +1 -1
- package/bundled-test-harness/assets/{token-usage-test-BQVbK3X0.js → token-usage-test-C6sULooH.js} +1 -1
- package/bundled-test-harness/assets/{tools-DU8Rc15s.js → tools-UJfD-_al.js} +1 -1
- package/bundled-test-harness/assets/{tools-test-B13yvEKE.js → tools-test-Brav31AV.js} +1 -1
- package/bundled-test-harness/assets/{worker-DHBEBihX.js → worker-9ph9_Lpv.js} +31 -26
- package/bundled-test-harness/index.html +8 -8
- package/bundled-test-harness/tests/index.html +8 -8
- package/bundled-types/choreo/beacons.ts +16 -4
- package/bundled-types/choreo/changeset.ts +25 -5
- package/bundled-types/choreo/choreo.gts +245 -3
- package/bundled-types/choreo/far.ts +39 -0
- package/bundled-types/choreo/index.ts +2 -0
- package/bundled-types/choreo/run.ts +104 -1
- package/bundled-types/host-app/lib/realm-runner/worker.ts +8 -51
- package/bundled-types/runtime-common/realm-runner-script.d.ts +3 -0
- package/package.json +1 -1
- package/bundled-test-harness/assets/choreo-B3Z9QhDH.js +0 -2
- package/bundled-test-harness/assets/clip-BjcXuTwN.js +0 -2
- package/bundled-test-harness/assets/film-DMDPP7ma.js +0 -2
- package/bundled-test-harness/assets/plate-66qnQ-M9.js +0 -2
- package/bundled-test-harness/assets/player-pygg1PwA.js +0 -2
- package/bundled-test-harness/assets/titles-Ccpcl-jq.js +0 -2
|
@@ -0,0 +1,1150 @@
|
|
|
1
|
+
// Pretui — StickToBottom: the scroll container of a live transcript. Follows new content while the reader is at the end, lets go the moment they read elsewhere, and keeps their line still when older history loads above.
|
|
2
|
+
import Component from '@glimmer/component';
|
|
3
|
+
import { tracked } from '@glimmer/tracking';
|
|
4
|
+
import { modifier } from 'ember-modifier';
|
|
5
|
+
import type Owner from '@ember/owner';
|
|
6
|
+
|
|
7
|
+
// ── The follow model ─────────────────────────────────────────────────────
|
|
8
|
+
// Two states, and the asymmetry between them is the design:
|
|
9
|
+
//
|
|
10
|
+
// following content growth scrolls to the end, instantly (a smooth
|
|
11
|
+
// catch-up animation behind a stream reads as lag)
|
|
12
|
+
// detached content growth leaves the viewport alone
|
|
13
|
+
//
|
|
14
|
+
// Detaching is easy on purpose: any upward user scroll of more than half a
|
|
15
|
+
// pixel, a wheel-up, or selecting text while the content is still changing.
|
|
16
|
+
// Re-pinning is deliberate: reaching within `@threshold` of the end, or a
|
|
17
|
+
// `scrollToEnd()` call. So a one-pixel nudge up lets go, and a collapse that
|
|
18
|
+
// shortens the content still clamps the reader to the end.
|
|
19
|
+
//
|
|
20
|
+
// No timer anywhere (realm law, and a re-arming timer would hang
|
|
21
|
+
// `await settled()`). The component's own scrolls are recognised by the
|
|
22
|
+
// position they were aiming at, not by a time window, and that record is
|
|
23
|
+
// cleared by the first scroll event that does not match it.
|
|
24
|
+
|
|
25
|
+
const UNPIN_EPSILON = 0.5;
|
|
26
|
+
|
|
27
|
+
// One device pixel in CSS pixels: the finest step a scroll can show.
|
|
28
|
+
const devicePixel = () => 1 / (globalThis.devicePixelRatio || 1);
|
|
29
|
+
// A scroll the component writes lands on a whole device pixel. A target
|
|
30
|
+
// between two let the browser round it one way and the next correction
|
|
31
|
+
// the other: the transcript shivered a pixel down and back while a row
|
|
32
|
+
// ran.
|
|
33
|
+
const toDevicePixel = (value: number) => {
|
|
34
|
+
let px = devicePixel();
|
|
35
|
+
return Math.round(value / px) * px;
|
|
36
|
+
};
|
|
37
|
+
// How long a reader's own move up from the end is remembered: nothing
|
|
38
|
+
// follows them back down (not the threshold, not growth, not a revision)
|
|
39
|
+
// until it passes, so momentum landing near the end doesn't undo it.
|
|
40
|
+
const UP_INTENT_MS = 1500;
|
|
41
|
+
const OWN_SCROLL_SLACK = 2;
|
|
42
|
+
const TRANSCRIPT_ID = 'data-transcript-id';
|
|
43
|
+
// How long after the reader's wheel, touch or key a scroll still counts as
|
|
44
|
+
// theirs (a wheel's momentum, a key's smooth scroll)
|
|
45
|
+
const INPUT_MS = 600;
|
|
46
|
+
// How long the element the reader pressed (a toggle, a choice, Undo) stays
|
|
47
|
+
// the anchor: what it opens or closes moves around it, not it
|
|
48
|
+
const PRESS_MS = 2000;
|
|
49
|
+
// How long the app's own navigation target (reveal) stays the anchor
|
|
50
|
+
const NAVIGATE_MS = 2000;
|
|
51
|
+
// The reading line: where in the viewport the reader's attention is taken
|
|
52
|
+
// to be, as a fraction of its height from the top
|
|
53
|
+
const READING_LINE = 1 / 3;
|
|
54
|
+
// Anchor candidates: messages, rows, steps, answer blocks. Picking stops at
|
|
55
|
+
// the first marked element under the reading line that fits in the view
|
|
56
|
+
const ANCHOR_ATTR = 'data-scroll-anchor';
|
|
57
|
+
const SCROLL_KEYS = new Set([
|
|
58
|
+
'ArrowUp',
|
|
59
|
+
'ArrowDown',
|
|
60
|
+
'PageUp',
|
|
61
|
+
'PageDown',
|
|
62
|
+
'Home',
|
|
63
|
+
'End',
|
|
64
|
+
' ',
|
|
65
|
+
]);
|
|
66
|
+
const NESTED_REGION = '[data-scroll-region]';
|
|
67
|
+
|
|
68
|
+
export type FollowChangeSource = 'user' | 'api';
|
|
69
|
+
|
|
70
|
+
export interface StickToBottomApi {
|
|
71
|
+
/** Whether content growth currently scrolls to the end. */
|
|
72
|
+
readonly following: boolean;
|
|
73
|
+
/** The scrolling viewport, once rendered. Not tracked: read it from a
|
|
74
|
+
* modifier or an action, not from a template. */
|
|
75
|
+
readonly scrollElement: HTMLElement | undefined;
|
|
76
|
+
/** Scroll to the end and follow again. `smooth` degrades to `auto` under
|
|
77
|
+
* reduced motion. */
|
|
78
|
+
scrollToEnd(behavior?: ScrollBehavior): void;
|
|
79
|
+
/** Bring the row carrying `data-transcript-id="<id>"` into view and stop
|
|
80
|
+
* following. Returns false when no such row is in the DOM. */
|
|
81
|
+
reveal(id: string, opts?: { block?: 'start' | 'center' }): boolean;
|
|
82
|
+
/** Bring an element in the content into view and stop following; it is
|
|
83
|
+
* the anchor while the scroll lands. The app's way to show something (a
|
|
84
|
+
* gate to review, the first unread message), instead of its own
|
|
85
|
+
* scrollIntoView, which would fight the anchor. */
|
|
86
|
+
navigate(
|
|
87
|
+
element: HTMLElement,
|
|
88
|
+
opts?: { block?: 'start' | 'center'; behavior?: ScrollBehavior },
|
|
89
|
+
): void;
|
|
90
|
+
/** Hold the anchor now: one step of the scroll anchoring protocol. A host
|
|
91
|
+
* with a motion layer calls it after the render pass commits and before
|
|
92
|
+
* the motion layer measures, so the scroll lands before anything
|
|
93
|
+
* measures. */
|
|
94
|
+
settle(): void;
|
|
95
|
+
/** With `@holdTail`: rows that arrived after the reader detached, held
|
|
96
|
+
* back from the list until they return to the end. 0 otherwise. */
|
|
97
|
+
readonly pendingCount: number;
|
|
98
|
+
/** With `@holdTail`: `items` cut at the last row the reader had when they
|
|
99
|
+
* detached; the whole list otherwise. Use it as a helper:
|
|
100
|
+
* `{{#each (scroll.visibleItems this.rows) …}}` */
|
|
101
|
+
visibleItems<T extends { id: string }>(items: T[]): T[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface StickToBottomSignature {
|
|
105
|
+
Args: {
|
|
106
|
+
/** Controlled following state. Pair with `@onFollowChange`. */
|
|
107
|
+
following?: boolean;
|
|
108
|
+
/** Uncontrolled initial state. Default true. */
|
|
109
|
+
defaultFollowing?: boolean;
|
|
110
|
+
/** Fires when following changes. `by` says whether the reader caused it
|
|
111
|
+
* (`user`) or a `scrollToEnd` / `reveal` call did (`api`). */
|
|
112
|
+
onFollowChange?: (following: boolean, by: FollowChangeSource) => void;
|
|
113
|
+
/** The content is still changing (a response is streaming). While
|
|
114
|
+
* active, selecting text detaches; when it turns false, a following
|
|
115
|
+
* viewport settles on the final end. */
|
|
116
|
+
active?: boolean;
|
|
117
|
+
/** Distance from the end, in px, that counts as "at the end" when
|
|
118
|
+
* re-pinning. Default 8. */
|
|
119
|
+
threshold?: number;
|
|
120
|
+
/** Called once each time the reader scrolls up into `@startMargin` of the
|
|
121
|
+
* top. The reader's line is held still across whatever it prepends. */
|
|
122
|
+
onReachStart?: () => Promise<void> | void;
|
|
123
|
+
/** Distance from the top, in px, that triggers `@onReachStart`. Default 400. */
|
|
124
|
+
startMargin?: number;
|
|
125
|
+
/** Accessible name. Supplying one promotes the viewport to a `region`. */
|
|
126
|
+
label?: string;
|
|
127
|
+
/** Hold the tail while the reader is away: rows arriving after they
|
|
128
|
+
* detach stay out of the list (render `scroll.visibleItems(rows)`) and
|
|
129
|
+
* are counted in `scroll.pendingCount`, then land together when they
|
|
130
|
+
* return to the end. Needs `@itemIds`. */
|
|
131
|
+
holdTail?: boolean;
|
|
132
|
+
/** the ids of the caller's rows, in order. Read with `@holdTail` */
|
|
133
|
+
itemIds?: string[];
|
|
134
|
+
/** Any value that changes when the content changes (a message lands, a
|
|
135
|
+
* row grows). While following, a change pins to the end in the same
|
|
136
|
+
* render commit, before anything measures the frame after the render,
|
|
137
|
+
* so a motion layer sees the follow as part of the pass instead of a
|
|
138
|
+
* jump after it. Without it, growth is pinned by the resize observer. */
|
|
139
|
+
revision?: unknown;
|
|
140
|
+
/** Turn on the prominence ramp inside the transcript: settled work
|
|
141
|
+
* (ToolRow, ToolGroup, WorkItem) recedes to this opacity. true means
|
|
142
|
+
* 0.45 */
|
|
143
|
+
recede?: boolean | number;
|
|
144
|
+
};
|
|
145
|
+
Blocks: {
|
|
146
|
+
default: [StickToBottomApi];
|
|
147
|
+
/** Rendered over the viewport, positioned against the component's root:
|
|
148
|
+
* the JumpChip slot. */
|
|
149
|
+
overlay: [StickToBottomApi];
|
|
150
|
+
};
|
|
151
|
+
Element: HTMLDivElement;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function prefersReducedMotion(): boolean {
|
|
155
|
+
return (
|
|
156
|
+
typeof window !== 'undefined' &&
|
|
157
|
+
typeof window.matchMedia === 'function' &&
|
|
158
|
+
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
type RecordedAnchor =
|
|
163
|
+
| { kind: 'end'; gap: number }
|
|
164
|
+
| {
|
|
165
|
+
kind: 'navigation' | 'pressed' | 'reading';
|
|
166
|
+
chain: { el: HTMLElement; offset: number; top: number }[];
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
function distanceFromEnd(el: HTMLElement): number {
|
|
170
|
+
return el.scrollHeight - el.clientHeight - el.scrollTop;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Installs every listener and observer once, for the component's lifetime.
|
|
174
|
+
* It takes only the component, which never changes, so it never re-runs. */
|
|
175
|
+
const follow = modifier((viewport: HTMLElement, [owner]: [StickToBottom]) =>
|
|
176
|
+
owner.install(viewport),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
/** Sets one CSS custom property on the element, or removes it, without
|
|
180
|
+
* touching the rest of its style attribute (which may be the caller's). */
|
|
181
|
+
const customProperty = modifier(
|
|
182
|
+
(el: HTMLElement, [name, value]: [string, string | undefined]) => {
|
|
183
|
+
if (value === undefined) {
|
|
184
|
+
el.style.removeProperty(name);
|
|
185
|
+
} else {
|
|
186
|
+
el.style.setProperty(name, value);
|
|
187
|
+
}
|
|
188
|
+
return () => el.style.removeProperty(name);
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
/** Reports a value to a callback on install and on every change, so a
|
|
193
|
+
* component can react to an argument changing without a side-effecting
|
|
194
|
+
* getter. */
|
|
195
|
+
const watch = modifier(
|
|
196
|
+
(
|
|
197
|
+
_el: HTMLElement,
|
|
198
|
+
[callback, value]: [(value: unknown) => void, unknown],
|
|
199
|
+
) => {
|
|
200
|
+
callback(value);
|
|
201
|
+
},
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The scroll owner of a live transcript.
|
|
206
|
+
*
|
|
207
|
+
* ```hbs
|
|
208
|
+
* <StickToBottom @active={{this.generating}} @label='Conversation'>
|
|
209
|
+
* <:default as |scroll|>…rows, each carrying data-transcript-id…</:default>
|
|
210
|
+
* <:overlay as |scroll|>
|
|
211
|
+
* <JumpChip @visible={{not scroll.following}} @onJump={{fn scroll.scrollToEnd 'smooth'}} />
|
|
212
|
+
* </:overlay>
|
|
213
|
+
* </StickToBottom>
|
|
214
|
+
* ```
|
|
215
|
+
*
|
|
216
|
+
* Its own scrolls are recognised by the position they aim at, not by a timer
|
|
217
|
+
* window. Something the reader opens that grows the content out of view
|
|
218
|
+
* detaches instead of being pulled back to the end. Holding the reader's
|
|
219
|
+
* line when history loads above is part of the component, not left to each
|
|
220
|
+
* caller. The rules it holds are ADR 012.
|
|
221
|
+
*/
|
|
222
|
+
export class StickToBottom extends Component<StickToBottomSignature> {
|
|
223
|
+
@tracked private internalFollowing = this.args.defaultFollowing ?? true;
|
|
224
|
+
@tracked anchoring = false;
|
|
225
|
+
/** with @holdTail: the last row id the reader had when they detached */
|
|
226
|
+
@tracked private heldAfter: string | undefined;
|
|
227
|
+
|
|
228
|
+
private viewport: HTMLElement | undefined;
|
|
229
|
+
private content: HTMLElement | undefined;
|
|
230
|
+
/** scrollTop the component itself is scrolling to, until a scroll event
|
|
231
|
+
* shows something else moved the viewport. */
|
|
232
|
+
private ownTarget: number | undefined;
|
|
233
|
+
/** whether the scroll to `ownTarget` is a smooth one, which spans frames */
|
|
234
|
+
private ownSmooth = false;
|
|
235
|
+
private get smoothInFlight(): boolean {
|
|
236
|
+
return this.ownSmooth && this.ownTarget !== undefined;
|
|
237
|
+
}
|
|
238
|
+
private lastTop = 0;
|
|
239
|
+
/** when the reader last moved up from the end themselves */
|
|
240
|
+
private leftAt = -Infinity;
|
|
241
|
+
private get holdingIntent() {
|
|
242
|
+
return performance.now() - this.leftAt < UP_INTENT_MS;
|
|
243
|
+
}
|
|
244
|
+
private lastActive = false;
|
|
245
|
+
private reachPending = false;
|
|
246
|
+
private wasInStartMargin = false;
|
|
247
|
+
/** the anchor held through the next settle: what it is, the element (and
|
|
248
|
+
* its marked ancestors, to fall back on if it leaves), its offset from
|
|
249
|
+
* the viewport's top and its top, by layout, or the gap to the end when it
|
|
250
|
+
* is the end */
|
|
251
|
+
private recorded: RecordedAnchor | undefined;
|
|
252
|
+
/** the element the reader last pressed in the content, and when */
|
|
253
|
+
private pressed: { el: HTMLElement; at: number } | undefined;
|
|
254
|
+
/** the element the app last brought into view, and when */
|
|
255
|
+
private navigation: { el: HTMLElement; at: number } | undefined;
|
|
256
|
+
/** when the reader last scrolled by hand (wheel, touch, a scrolling key)
|
|
257
|
+
* and whether a pointer is down on the viewport (its scrollbar, a drag):
|
|
258
|
+
* only those moves are theirs */
|
|
259
|
+
private inputAt = -Infinity;
|
|
260
|
+
/** the content's and viewport's heights at the last scroll */
|
|
261
|
+
private lastHeights: [number, number] = [0, 0];
|
|
262
|
+
private sizeChanged(el: HTMLElement): boolean {
|
|
263
|
+
return (
|
|
264
|
+
el.scrollHeight !== this.lastHeights[0] ||
|
|
265
|
+
el.clientHeight !== this.lastHeights[1]
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
private pointerHeld = false;
|
|
269
|
+
private get readerScrolling(): boolean {
|
|
270
|
+
return this.pointerHeld || performance.now() - this.inputAt < INPUT_MS;
|
|
271
|
+
}
|
|
272
|
+
private noteInput = () => {
|
|
273
|
+
this.inputAt = performance.now();
|
|
274
|
+
// the reader's hand on the scroll takes over from what they pressed and
|
|
275
|
+
// from any navigation: from here the reading line is the anchor
|
|
276
|
+
this.pressed = undefined;
|
|
277
|
+
this.navigation = undefined;
|
|
278
|
+
};
|
|
279
|
+
// A press on the viewport itself is its scrollbar (or a drag on its
|
|
280
|
+
// bare edge): a scroll that follows is the reader's. A press on the
|
|
281
|
+
// content is a click: what it opens or closes is not a scroll, so a
|
|
282
|
+
// collapse that pulls the position up must not let go of the end.
|
|
283
|
+
private onPointerDown = (event: PointerEvent) => {
|
|
284
|
+
if (event.target === this.viewport) {
|
|
285
|
+
this.pointerHeld = true;
|
|
286
|
+
this.noteInput();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
this.press(event.target);
|
|
290
|
+
};
|
|
291
|
+
private onPointerRelease = () => {
|
|
292
|
+
if (this.pointerHeld) {
|
|
293
|
+
this.pointerHeld = false;
|
|
294
|
+
this.noteInput();
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
// Listened for on the owner document: after a click on text that cannot
|
|
298
|
+
// take focus, a scrolling key's target is the body, and the browser
|
|
299
|
+
// scrolls the viewport the click was in.
|
|
300
|
+
private onScrollKey = (event: KeyboardEvent) => {
|
|
301
|
+
let target = event.target;
|
|
302
|
+
let inViewport =
|
|
303
|
+
target instanceof Node && Boolean(this.viewport?.contains(target));
|
|
304
|
+
let onBody =
|
|
305
|
+
target === this.viewport?.ownerDocument.body ||
|
|
306
|
+
target === this.viewport?.ownerDocument.documentElement;
|
|
307
|
+
if (!inViewport && !onBody) {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (inViewport && (event.key === 'Enter' || event.key === ' ')) {
|
|
311
|
+
// a press on a focused control: Space on the viewport itself scrolls
|
|
312
|
+
if (target !== this.viewport) {
|
|
313
|
+
this.press(target);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (SCROLL_KEYS.has(event.key)) {
|
|
318
|
+
this.noteInput();
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
// Focus that lands in the content, other than by a pointer press, makes
|
|
322
|
+
// the focused element the anchor: the browser scrolls it into view (Tab,
|
|
323
|
+
// a skip link, the app's focus()), and that scroll is not a clamp to undo.
|
|
324
|
+
// The anchor is recorded again at the next settle or scroll, after the
|
|
325
|
+
// browser's scroll has landed.
|
|
326
|
+
private onFocusIn = (event: FocusEvent) => {
|
|
327
|
+
let target = event.target;
|
|
328
|
+
if (!(target instanceof HTMLElement) || !this.content?.contains(target)) {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
let pressed = this.pressed;
|
|
332
|
+
if (
|
|
333
|
+
pressed &&
|
|
334
|
+
performance.now() - pressed.at < INPUT_MS &&
|
|
335
|
+
target.contains(pressed.el)
|
|
336
|
+
) {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
this.noteInput();
|
|
340
|
+
this.navigation = { el: target, at: performance.now() };
|
|
341
|
+
this.recorded = undefined;
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
/** The reader pressed something in the content: it is the anchor while
|
|
345
|
+
* what it opens or closes lands. */
|
|
346
|
+
private press(target: EventTarget | null) {
|
|
347
|
+
if (!(target instanceof HTMLElement) || !this.content?.contains(target)) {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
this.pressed = { el: target, at: performance.now() };
|
|
351
|
+
this.record();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
readonly api: StickToBottomApi;
|
|
355
|
+
|
|
356
|
+
constructor(owner: Owner, args: StickToBottomSignature['Args']) {
|
|
357
|
+
super(owner, args);
|
|
358
|
+
let component = this;
|
|
359
|
+
this.api = {
|
|
360
|
+
get following() {
|
|
361
|
+
return component.following;
|
|
362
|
+
},
|
|
363
|
+
get scrollElement() {
|
|
364
|
+
return component.viewport;
|
|
365
|
+
},
|
|
366
|
+
scrollToEnd: (behavior?: ScrollBehavior) => this.scrollToEnd(behavior),
|
|
367
|
+
settle: () => this.settle(),
|
|
368
|
+
navigate: (
|
|
369
|
+
element: HTMLElement,
|
|
370
|
+
opts?: { block?: 'start' | 'center'; behavior?: ScrollBehavior },
|
|
371
|
+
) => this.navigate(element, opts),
|
|
372
|
+
reveal: (id: string, opts?: { block?: 'start' | 'center' }) =>
|
|
373
|
+
this.reveal(id, opts),
|
|
374
|
+
get pendingCount() {
|
|
375
|
+
return component.pendingCount;
|
|
376
|
+
},
|
|
377
|
+
visibleItems: <T extends { id: string }>(items: T[]) =>
|
|
378
|
+
component.visibleItems(items),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
get following(): boolean {
|
|
383
|
+
return this.args.following ?? this.internalFollowing;
|
|
384
|
+
}
|
|
385
|
+
get threshold(): number {
|
|
386
|
+
return this.args.threshold ?? 8;
|
|
387
|
+
}
|
|
388
|
+
get startMargin(): number {
|
|
389
|
+
return this.args.startMargin ?? 400;
|
|
390
|
+
}
|
|
391
|
+
get role(): string | undefined {
|
|
392
|
+
return this.args.label ? 'region' : undefined;
|
|
393
|
+
}
|
|
394
|
+
get recedeOpacity(): string | undefined {
|
|
395
|
+
let recede = this.args.recede;
|
|
396
|
+
if (recede === undefined || recede === false) {
|
|
397
|
+
return undefined;
|
|
398
|
+
}
|
|
399
|
+
return String(recede === true ? 0.45 : Math.min(1, Math.max(0, recede)));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ── holding the tail ───────────────────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
private get holding(): boolean {
|
|
405
|
+
return (
|
|
406
|
+
Boolean(this.args.holdTail) &&
|
|
407
|
+
!this.following &&
|
|
408
|
+
this.heldAfter !== undefined
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
get pendingCount(): number {
|
|
412
|
+
if (!this.holding) {
|
|
413
|
+
return 0;
|
|
414
|
+
}
|
|
415
|
+
let ids = this.args.itemIds ?? [];
|
|
416
|
+
let index = ids.indexOf(this.heldAfter!);
|
|
417
|
+
return index < 0 ? 0 : ids.length - index - 1;
|
|
418
|
+
}
|
|
419
|
+
visibleItems<T extends { id: string }>(items: T[]): T[] {
|
|
420
|
+
if (!this.holding) {
|
|
421
|
+
return items;
|
|
422
|
+
}
|
|
423
|
+
let index = items.findIndex((item) => item.id === this.heldAfter);
|
|
424
|
+
return index < 0 ? items : items.slice(0, index + 1);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ── state changes ──────────────────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
private setFollowing(next: boolean, by: FollowChangeSource) {
|
|
430
|
+
if (next === this.following) {
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (this.args.following === undefined) {
|
|
434
|
+
this.internalFollowing = next;
|
|
435
|
+
}
|
|
436
|
+
if (this.args.holdTail) {
|
|
437
|
+
// leaving: freeze the list at what the reader had; returning: release
|
|
438
|
+
let ids = this.args.itemIds ?? [];
|
|
439
|
+
this.heldAfter = next ? undefined : ids[ids.length - 1];
|
|
440
|
+
}
|
|
441
|
+
this.args.onFollowChange?.(next, by);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Programmatic scroll; the scroll event it causes is recognised as ours. */
|
|
445
|
+
private scrollTo(top: number, behavior: ScrollBehavior = 'auto') {
|
|
446
|
+
let el = this.viewport;
|
|
447
|
+
if (!el) {
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
let max = Math.max(0, el.scrollHeight - el.clientHeight);
|
|
451
|
+
let target = toDevicePixel(Math.min(Math.max(0, top), max));
|
|
452
|
+
if (
|
|
453
|
+
Math.abs(el.scrollTop - target) < Math.max(UNPIN_EPSILON, devicePixel())
|
|
454
|
+
) {
|
|
455
|
+
// no scroll event will come, so leave no record waiting for one
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
this.ownTarget = target;
|
|
459
|
+
this.ownSmooth = behavior === 'smooth' && !prefersReducedMotion();
|
|
460
|
+
if (this.ownSmooth) {
|
|
461
|
+
el.scrollTo({ top: target, behavior: 'smooth' });
|
|
462
|
+
} else {
|
|
463
|
+
// assigning scrollTop bypasses any CSS scroll-behavior: smooth
|
|
464
|
+
el.scrollTop = target;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
private pinToEnd() {
|
|
469
|
+
let el = this.viewport;
|
|
470
|
+
if (el && distanceFromEnd(el) > UNPIN_EPSILON) {
|
|
471
|
+
this.scrollTo(el.scrollHeight);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
scrollToEnd = (behavior?: ScrollBehavior) => {
|
|
476
|
+
let el = this.viewport;
|
|
477
|
+
if (!el) {
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
this.setFollowing(true, 'api');
|
|
481
|
+
this.scrollTo(el.scrollHeight, behavior ?? 'auto');
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
reveal = (id: string, opts?: { block?: 'start' | 'center' }): boolean => {
|
|
485
|
+
let el = this.viewport;
|
|
486
|
+
let target = this.findRow(id);
|
|
487
|
+
if (!el || !target) {
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
this.setFollowing(false, 'api');
|
|
491
|
+
this.navigation = { el: target, at: performance.now() };
|
|
492
|
+
let offset = this.offsetOf(target);
|
|
493
|
+
if (opts?.block === 'center') {
|
|
494
|
+
offset -=
|
|
495
|
+
(el.clientHeight -
|
|
496
|
+
target.getBoundingClientRect().height / this.scale()) /
|
|
497
|
+
2;
|
|
498
|
+
}
|
|
499
|
+
this.scrollTo(el.scrollTop + offset);
|
|
500
|
+
return true;
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
navigate = (
|
|
504
|
+
element: HTMLElement,
|
|
505
|
+
opts?: { block?: 'start' | 'center'; behavior?: ScrollBehavior },
|
|
506
|
+
) => {
|
|
507
|
+
let el = this.viewport;
|
|
508
|
+
if (!el || !this.content?.contains(element)) {
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
this.setFollowing(false, 'api');
|
|
512
|
+
this.navigation = { el: element, at: performance.now() };
|
|
513
|
+
let offset = this.offsetOf(element);
|
|
514
|
+
if (opts?.block === 'center') {
|
|
515
|
+
offset -=
|
|
516
|
+
(el.clientHeight -
|
|
517
|
+
element.getBoundingClientRect().height / this.scale()) /
|
|
518
|
+
2;
|
|
519
|
+
}
|
|
520
|
+
this.scrollTo(el.scrollTop + offset, opts?.behavior ?? 'auto');
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
/** Ratio of rendered to layout size. Bounding rects come back transformed
|
|
524
|
+
* (a zoomed or scaled ancestor), scrollTop does not, so every rect delta is
|
|
525
|
+
* divided by this before it is added to a scroll position. */
|
|
526
|
+
private scale(): number {
|
|
527
|
+
let el = this.viewport;
|
|
528
|
+
if (!el || el.offsetHeight === 0) {
|
|
529
|
+
return 1;
|
|
530
|
+
}
|
|
531
|
+
let rendered = el.getBoundingClientRect().height;
|
|
532
|
+
return rendered > 0 ? rendered / el.offsetHeight : 1;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** A row's distance below the viewport's top edge, in layout pixels. */
|
|
536
|
+
private offsetOf(row: HTMLElement): number {
|
|
537
|
+
let el = this.viewport;
|
|
538
|
+
if (!el) {
|
|
539
|
+
return 0;
|
|
540
|
+
}
|
|
541
|
+
return (
|
|
542
|
+
(row.getBoundingClientRect().top - el.getBoundingClientRect().top) /
|
|
543
|
+
this.scale()
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private findRow(id: string): HTMLElement | undefined {
|
|
548
|
+
let el = this.viewport;
|
|
549
|
+
if (!el) {
|
|
550
|
+
return undefined;
|
|
551
|
+
}
|
|
552
|
+
for (let row of Array.from(
|
|
553
|
+
el.querySelectorAll<HTMLElement>(`[${TRANSCRIPT_ID}]`),
|
|
554
|
+
)) {
|
|
555
|
+
if (row.getAttribute(TRANSCRIPT_ID) === id) {
|
|
556
|
+
return row;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return undefined;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ── argument watchers ──────────────────────────────────────────────────
|
|
563
|
+
|
|
564
|
+
activeChanged = (value: unknown) => {
|
|
565
|
+
let active = Boolean(value);
|
|
566
|
+
if (this.lastActive && !active) {
|
|
567
|
+
// the stream settled: hold the anchor on its final layout
|
|
568
|
+
this.settle({ inRender: true });
|
|
569
|
+
}
|
|
570
|
+
this.lastActive = active;
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
// the content changed in this render: the anchor is held in the same
|
|
574
|
+
// commit (not the frame after, when the resize observer would), so a
|
|
575
|
+
// motion layer measuring this pass sees the result, not a jump after it
|
|
576
|
+
revisionChanged = () => {
|
|
577
|
+
this.settle({ inRender: true });
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
followingArgChanged = (value: unknown) => {
|
|
581
|
+
// a controlling caller switching following back on means "go to the end"
|
|
582
|
+
if (value === true) {
|
|
583
|
+
this.pinToEnd();
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
// ── listeners ──────────────────────────────────────────────────────────
|
|
588
|
+
|
|
589
|
+
private onScroll = () => {
|
|
590
|
+
let el = this.viewport;
|
|
591
|
+
if (!el) {
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
let top = el.scrollTop;
|
|
595
|
+
let movedUp = top < this.lastTop - UNPIN_EPSILON;
|
|
596
|
+
|
|
597
|
+
let own = false;
|
|
598
|
+
if (this.ownTarget !== undefined) {
|
|
599
|
+
let before = this.lastTop - this.ownTarget;
|
|
600
|
+
let now = top - this.ownTarget;
|
|
601
|
+
if (Math.abs(now) <= OWN_SCROLL_SLACK) {
|
|
602
|
+
own = true;
|
|
603
|
+
this.ownTarget = undefined;
|
|
604
|
+
} else if (!movedUp && now < 0) {
|
|
605
|
+
// still on its way down to the target
|
|
606
|
+
own = true;
|
|
607
|
+
} else if (
|
|
608
|
+
this.ownSmooth &&
|
|
609
|
+
!this.readerScrolling &&
|
|
610
|
+
Math.sign(now) === Math.sign(before) &&
|
|
611
|
+
Math.abs(now) < Math.abs(before)
|
|
612
|
+
) {
|
|
613
|
+
// a smooth scroll still on its way up to the target
|
|
614
|
+
own = true;
|
|
615
|
+
} else {
|
|
616
|
+
this.ownTarget = undefined;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (!own && this.readerScrolling) {
|
|
621
|
+
// a touch's momentum, or a wheel's, scrolls on after the last input
|
|
622
|
+
// event: each of its scrolls keeps the move the reader's
|
|
623
|
+
this.inputAt = performance.now();
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (!own) {
|
|
627
|
+
// the reader's own move up wins, even a small one near the end, and
|
|
628
|
+
// is remembered for a moment
|
|
629
|
+
if (movedUp && !this.readerScrolling && this.sizeChanged(el)) {
|
|
630
|
+
// the position fell without the reader's hand because the layout
|
|
631
|
+
// moved: content shrank or the viewport grew. The anchor recorded
|
|
632
|
+
// before it is still the one to hold; settle puts it back. (A move
|
|
633
|
+
// up with no size change is a scroll someone asked for, the app's
|
|
634
|
+
// scrollIntoView included.)
|
|
635
|
+
this.lastTop = top;
|
|
636
|
+
this.settle();
|
|
637
|
+
this.writeEdges();
|
|
638
|
+
return;
|
|
639
|
+
} else if (movedUp) {
|
|
640
|
+
this.leftAt = performance.now();
|
|
641
|
+
this.setFollowing(false, 'user');
|
|
642
|
+
} else if (
|
|
643
|
+
distanceFromEnd(el) <= this.threshold &&
|
|
644
|
+
// the reader scrolling back down to the end follows at once; the
|
|
645
|
+
// end arriving under them by itself (growth, a reflow) waits out
|
|
646
|
+
// their move up
|
|
647
|
+
(top > this.lastTop + UNPIN_EPSILON || !this.holdingIntent)
|
|
648
|
+
) {
|
|
649
|
+
this.setFollowing(true, 'user');
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
let inStartMargin = top <= this.startMargin;
|
|
654
|
+
if (inStartMargin && !this.wasInStartMargin && movedUp) {
|
|
655
|
+
void this.reachStart();
|
|
656
|
+
}
|
|
657
|
+
this.wasInStartMargin = inStartMargin;
|
|
658
|
+
this.lastTop = top;
|
|
659
|
+
this.lastHeights = [el.scrollHeight, el.clientHeight];
|
|
660
|
+
// a scroll on a layout that changed since the anchor was recorded (a
|
|
661
|
+
// late event from our own earlier scroll, landing after content grew)
|
|
662
|
+
// must not take the changed layout as the new baseline: hold the
|
|
663
|
+
// anchor through the change first
|
|
664
|
+
if (this.layoutChangedSinceRecord(el)) {
|
|
665
|
+
this.settle();
|
|
666
|
+
} else {
|
|
667
|
+
this.record();
|
|
668
|
+
}
|
|
669
|
+
this.writeEdges();
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
/** the content's and viewport's heights when the anchor was recorded */
|
|
673
|
+
private recordedHeights: [number, number] = [0, 0];
|
|
674
|
+
private layoutChangedSinceRecord(el: HTMLElement): boolean {
|
|
675
|
+
return (
|
|
676
|
+
el.scrollHeight !== this.recordedHeights[0] ||
|
|
677
|
+
el.clientHeight !== this.recordedHeights[1]
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
private onWheel = (event: WheelEvent) => {
|
|
682
|
+
this.noteInput();
|
|
683
|
+
if (event.deltaY >= 0) {
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
let target = event.target instanceof Element ? event.target : null;
|
|
687
|
+
let nested = target?.closest(NESTED_REGION);
|
|
688
|
+
if (nested && nested !== this.viewport && this.viewport?.contains(nested)) {
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
let el = this.viewport;
|
|
692
|
+
if (el && el.scrollHeight - el.clientHeight > 1) {
|
|
693
|
+
this.leftAt = performance.now();
|
|
694
|
+
this.setFollowing(false, 'user');
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
private onPointerUp = () => {
|
|
699
|
+
if (!this.args.active) {
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
let selection =
|
|
703
|
+
typeof window !== 'undefined' ? window.getSelection() : null;
|
|
704
|
+
if (
|
|
705
|
+
selection &&
|
|
706
|
+
!selection.isCollapsed &&
|
|
707
|
+
selection.anchorNode &&
|
|
708
|
+
this.viewport?.contains(selection.anchorNode)
|
|
709
|
+
) {
|
|
710
|
+
this.setFollowing(false, 'user');
|
|
711
|
+
}
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
private onResize = () => {
|
|
715
|
+
let el = this.viewport;
|
|
716
|
+
if (!el) {
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
this.settle();
|
|
720
|
+
this.lastTop = el.scrollTop;
|
|
721
|
+
// a frame's scroll events run before its resize callbacks, so a clamp
|
|
722
|
+
// that a shrink caused still compares against the heights before it
|
|
723
|
+
this.lastHeights = [el.scrollHeight, el.clientHeight];
|
|
724
|
+
this.writeEdges();
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
// ── the anchor ─────────────────────────────────────────────────────────
|
|
728
|
+
// One invariant, held in every settle: one element keeps its distance
|
|
729
|
+
// from the viewport's top, by layout, from one settle to the next; or,
|
|
730
|
+
// when the anchor is the end, the content's bottom keeps its distance from
|
|
731
|
+
// the viewport's bottom. The anchor is chosen by priority:
|
|
732
|
+
//
|
|
733
|
+
// 1. the app's navigation target (reveal), for a moment after it
|
|
734
|
+
// 2. the element the reader pressed (a toggle, a choice), for a moment
|
|
735
|
+
// after, unless they have scrolled since
|
|
736
|
+
// 3. the end, while following
|
|
737
|
+
// 4. the reading line: the element a third of the way down the view
|
|
738
|
+
//
|
|
739
|
+
// Positions are read from layout (offsets), which a motion layer's
|
|
740
|
+
// transforms do not move, so the correction and the motion never fight.
|
|
741
|
+
// See packages/pretui/docs/scroll-anchoring-protocol.md.
|
|
742
|
+
|
|
743
|
+
/** an element's top by layout, which transforms do not move */
|
|
744
|
+
private layoutTop(el: HTMLElement): number {
|
|
745
|
+
let y = 0;
|
|
746
|
+
for (
|
|
747
|
+
let e: HTMLElement | null = el;
|
|
748
|
+
e;
|
|
749
|
+
e = e.offsetParent as HTMLElement | null
|
|
750
|
+
) {
|
|
751
|
+
y += e.offsetTop;
|
|
752
|
+
}
|
|
753
|
+
return y;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** The element under the reading line: down through the content to the
|
|
757
|
+
* first marked anchor that fits in the view, or the smallest element the
|
|
758
|
+
* line crosses. */
|
|
759
|
+
private readingElement(): HTMLElement | undefined {
|
|
760
|
+
let el = this.viewport;
|
|
761
|
+
let content = this.content;
|
|
762
|
+
if (!el || !content) {
|
|
763
|
+
return undefined;
|
|
764
|
+
}
|
|
765
|
+
let view = el.getBoundingClientRect();
|
|
766
|
+
let line = view.top + view.height * READING_LINE;
|
|
767
|
+
let pick: HTMLElement | undefined;
|
|
768
|
+
let level: Element = content;
|
|
769
|
+
for (let depth = 0; depth < 10; depth++) {
|
|
770
|
+
// in flow only: a motion layer's overlays sit absolutely over
|
|
771
|
+
// everything and are never what the reader reads
|
|
772
|
+
let inFlow = (child: Element) =>
|
|
773
|
+
getComputedStyle(child).position !== 'absolute';
|
|
774
|
+
let next: HTMLElement | undefined;
|
|
775
|
+
let below: HTMLElement | undefined;
|
|
776
|
+
for (let child of Array.from(level.children)) {
|
|
777
|
+
let rect = child.getBoundingClientRect();
|
|
778
|
+
if (rect.height === 0) {
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
if (rect.top <= line && rect.bottom > line && inFlow(child)) {
|
|
782
|
+
next = child as HTMLElement;
|
|
783
|
+
break;
|
|
784
|
+
}
|
|
785
|
+
if (!below && rect.top > line && inFlow(child)) {
|
|
786
|
+
below = child as HTMLElement;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
// the line falls in a gap: the first element below it
|
|
790
|
+
next ??= below;
|
|
791
|
+
if (!next) {
|
|
792
|
+
break;
|
|
793
|
+
}
|
|
794
|
+
pick = next;
|
|
795
|
+
let rect = next.getBoundingClientRect();
|
|
796
|
+
if (next.hasAttribute(ANCHOR_ATTR) && rect.height <= view.height) {
|
|
797
|
+
break;
|
|
798
|
+
}
|
|
799
|
+
if (rect.top > line) {
|
|
800
|
+
break;
|
|
801
|
+
}
|
|
802
|
+
level = next;
|
|
803
|
+
}
|
|
804
|
+
return pick;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** The anchor the next settle holds, by the priority above. */
|
|
808
|
+
private chooseAnchor():
|
|
809
|
+
| { kind: 'end' }
|
|
810
|
+
| { kind: 'navigation' | 'pressed' | 'reading'; el: HTMLElement }
|
|
811
|
+
| undefined {
|
|
812
|
+
let now = performance.now();
|
|
813
|
+
let navigation = this.navigation;
|
|
814
|
+
if (
|
|
815
|
+
navigation &&
|
|
816
|
+
navigation.el.isConnected &&
|
|
817
|
+
now - navigation.at < NAVIGATE_MS
|
|
818
|
+
) {
|
|
819
|
+
return { kind: 'navigation', el: navigation.el };
|
|
820
|
+
}
|
|
821
|
+
let pressed = this.pressed;
|
|
822
|
+
if (pressed && pressed.el.isConnected && now - pressed.at < PRESS_MS) {
|
|
823
|
+
return { kind: 'pressed', el: pressed.el };
|
|
824
|
+
}
|
|
825
|
+
if (this.following) {
|
|
826
|
+
return { kind: 'end' };
|
|
827
|
+
}
|
|
828
|
+
let reading = this.readingElement();
|
|
829
|
+
return reading ? { kind: 'reading', el: reading } : undefined;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/** Record the anchor and where it sits now. */
|
|
833
|
+
private record() {
|
|
834
|
+
let el = this.viewport;
|
|
835
|
+
let anchor = this.chooseAnchor();
|
|
836
|
+
if (el) {
|
|
837
|
+
this.recordedHeights = [el.scrollHeight, el.clientHeight];
|
|
838
|
+
}
|
|
839
|
+
if (!el || !anchor) {
|
|
840
|
+
this.recorded = undefined;
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
if (anchor.kind === 'end') {
|
|
844
|
+
this.recorded = { kind: 'end', gap: distanceFromEnd(el) };
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
// the element and its marked ancestors: if what was pressed leaves the
|
|
848
|
+
// DOM (a choice collapsing into its answer), its row holds instead
|
|
849
|
+
let chain: HTMLElement[] = [anchor.el];
|
|
850
|
+
for (
|
|
851
|
+
let up = anchor.el.parentElement?.closest<HTMLElement>(
|
|
852
|
+
`[${ANCHOR_ATTR}]`,
|
|
853
|
+
);
|
|
854
|
+
up && this.content?.contains(up);
|
|
855
|
+
up = up.parentElement?.closest<HTMLElement>(`[${ANCHOR_ATTR}]`)
|
|
856
|
+
) {
|
|
857
|
+
chain.push(up);
|
|
858
|
+
}
|
|
859
|
+
this.recorded = {
|
|
860
|
+
kind: anchor.kind,
|
|
861
|
+
chain: chain.map((node) => {
|
|
862
|
+
let top = this.layoutTop(node);
|
|
863
|
+
return { el: node, offset: top - el.scrollTop, top };
|
|
864
|
+
}),
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/** Hold the recorded anchor through whatever changed since, then record
|
|
869
|
+
* again. Runs at every render commit, every resize, and when a stream
|
|
870
|
+
* settles. `inRender` when a render commit calls it: a change to
|
|
871
|
+
* following then waits for the commit to end, so the caller's
|
|
872
|
+
* `@onFollowChange` never updates state the render has already read. */
|
|
873
|
+
private settle(opts?: { inRender?: boolean }) {
|
|
874
|
+
let el = this.viewport;
|
|
875
|
+
let recorded = this.recorded;
|
|
876
|
+
if (!el) {
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
let minDrift = Math.max(UNPIN_EPSILON, devicePixel());
|
|
880
|
+
if (this.smoothInFlight) {
|
|
881
|
+
// The component's own smooth scroll is on its way: writing a position
|
|
882
|
+
// now would cut it short. Its target moves by what layout moved.
|
|
883
|
+
let target = this.ownTarget!;
|
|
884
|
+
if (this.following) {
|
|
885
|
+
target = el.scrollHeight;
|
|
886
|
+
} else if (recorded && recorded.kind !== 'end') {
|
|
887
|
+
let held = recorded.chain.find((entry) => entry.el.isConnected);
|
|
888
|
+
if (held) {
|
|
889
|
+
target += this.layoutTop(held.el) - held.top;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
let max = Math.max(0, el.scrollHeight - el.clientHeight);
|
|
893
|
+
if (Math.abs(Math.min(target, max) - this.ownTarget!) >= minDrift) {
|
|
894
|
+
this.scrollTo(target, 'smooth');
|
|
895
|
+
}
|
|
896
|
+
} else if (recorded?.kind === 'end') {
|
|
897
|
+
if (this.following) {
|
|
898
|
+
this.pinToEnd();
|
|
899
|
+
}
|
|
900
|
+
} else if (recorded) {
|
|
901
|
+
let held = recorded.chain.find((entry) => entry.el.isConnected);
|
|
902
|
+
if (held) {
|
|
903
|
+
// While the reader scrolls, the position is theirs: a settle can run
|
|
904
|
+
// between their scroll and its scroll event (a fast wheel), and
|
|
905
|
+
// holding the recorded offset would take their last step back. Only
|
|
906
|
+
// what layout moved is held. Otherwise the offset is, which also
|
|
907
|
+
// undoes a clamp when content shrank.
|
|
908
|
+
let drift = this.readerScrolling
|
|
909
|
+
? this.layoutTop(held.el) - held.top
|
|
910
|
+
: this.layoutTop(held.el) - el.scrollTop - held.offset;
|
|
911
|
+
// less than a device pixel of drift is rounding, not movement
|
|
912
|
+
if (Math.abs(drift) >= minDrift) {
|
|
913
|
+
this.scrollTo(el.scrollTop + drift);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
// a reader at the end who opened something, or moved focus to
|
|
917
|
+
// something, that grew past it is reading it now, not following the
|
|
918
|
+
// end
|
|
919
|
+
if (
|
|
920
|
+
recorded.kind !== 'reading' &&
|
|
921
|
+
this.following &&
|
|
922
|
+
distanceFromEnd(el) > this.threshold
|
|
923
|
+
) {
|
|
924
|
+
this.detachForAnchor(Boolean(opts?.inRender));
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
this.record();
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
private detachForAnchor(inRender: boolean) {
|
|
931
|
+
if (!inRender) {
|
|
932
|
+
this.setFollowing(false, 'user');
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
queueMicrotask(() => {
|
|
936
|
+
if (this.isDestroying || this.isDestroyed) {
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
let el = this.viewport;
|
|
940
|
+
if (el && this.following && distanceFromEnd(el) > this.threshold) {
|
|
941
|
+
this.setFollowing(false, 'user');
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
private async reachStart() {
|
|
947
|
+
let load = this.args.onReachStart;
|
|
948
|
+
if (!load || this.reachPending) {
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
this.reachPending = true;
|
|
952
|
+
this.anchoring = true;
|
|
953
|
+
this.record();
|
|
954
|
+
try {
|
|
955
|
+
await load();
|
|
956
|
+
} catch (error) {
|
|
957
|
+
// the caller's load failed: the reader can scroll to the start again
|
|
958
|
+
// to retry, and the error is reported, not left unhandled
|
|
959
|
+
console.error(error);
|
|
960
|
+
} finally {
|
|
961
|
+
// The prepend may already be laid out, or may land on the next resize:
|
|
962
|
+
// hold the reader's line now and once more a frame later.
|
|
963
|
+
this.settle();
|
|
964
|
+
let release = () => {
|
|
965
|
+
this.settle();
|
|
966
|
+
this.reachPending = false;
|
|
967
|
+
this.anchoring = false;
|
|
968
|
+
};
|
|
969
|
+
if (typeof requestAnimationFrame === 'function') {
|
|
970
|
+
requestAnimationFrame(release);
|
|
971
|
+
} else {
|
|
972
|
+
release();
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// ── edges and focus ────────────────────────────────────────────────────
|
|
978
|
+
|
|
979
|
+
/** Written as attributes, read only by CSS: a scroll costs no render. */
|
|
980
|
+
private writeEdges() {
|
|
981
|
+
let el = this.viewport;
|
|
982
|
+
if (!el) {
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
let scrollable = el.scrollHeight - el.clientHeight > 1;
|
|
986
|
+
el.dataset['startY'] = scrollable && el.scrollTop > 1 ? 'clipped' : 'flush';
|
|
987
|
+
// keyboard-reachable exactly when there is something to scroll
|
|
988
|
+
if (scrollable) {
|
|
989
|
+
if (el.getAttribute('tabindex') === null) {
|
|
990
|
+
el.setAttribute('tabindex', '0');
|
|
991
|
+
}
|
|
992
|
+
} else if (el.getAttribute('tabindex') === '0') {
|
|
993
|
+
el.removeAttribute('tabindex');
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// ── lifetime ───────────────────────────────────────────────────────────
|
|
998
|
+
|
|
999
|
+
install(viewport: HTMLElement): () => void {
|
|
1000
|
+
this.viewport = viewport;
|
|
1001
|
+
this.content =
|
|
1002
|
+
(viewport.querySelector(
|
|
1003
|
+
'[data-stick-to-bottom-content]',
|
|
1004
|
+
) as HTMLElement | null) ?? undefined;
|
|
1005
|
+
|
|
1006
|
+
viewport.addEventListener('scroll', this.onScroll, { passive: true });
|
|
1007
|
+
viewport.addEventListener('wheel', this.onWheel, { passive: true });
|
|
1008
|
+
viewport.addEventListener('pointerup', this.onPointerUp);
|
|
1009
|
+
viewport.addEventListener('pointerdown', this.onPointerDown);
|
|
1010
|
+
viewport.addEventListener('touchmove', this.noteInput, { passive: true });
|
|
1011
|
+
viewport.addEventListener('focusin', this.onFocusIn);
|
|
1012
|
+
let doc = viewport.ownerDocument;
|
|
1013
|
+
doc.addEventListener('keydown', this.onScrollKey);
|
|
1014
|
+
window.addEventListener('pointerup', this.onPointerRelease);
|
|
1015
|
+
window.addEventListener('pointercancel', this.onPointerRelease);
|
|
1016
|
+
|
|
1017
|
+
let observer: ResizeObserver | undefined;
|
|
1018
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
1019
|
+
observer = new ResizeObserver(this.onResize);
|
|
1020
|
+
observer.observe(viewport);
|
|
1021
|
+
if (this.content) {
|
|
1022
|
+
observer.observe(this.content);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
if (this.following) {
|
|
1027
|
+
this.pinToEnd();
|
|
1028
|
+
}
|
|
1029
|
+
this.record();
|
|
1030
|
+
this.lastTop = viewport.scrollTop;
|
|
1031
|
+
this.lastHeights = [viewport.scrollHeight, viewport.clientHeight];
|
|
1032
|
+
this.wasInStartMargin = viewport.scrollTop <= this.startMargin;
|
|
1033
|
+
this.writeEdges();
|
|
1034
|
+
|
|
1035
|
+
return () => {
|
|
1036
|
+
viewport.removeEventListener('scroll', this.onScroll);
|
|
1037
|
+
viewport.removeEventListener('wheel', this.onWheel);
|
|
1038
|
+
viewport.removeEventListener('pointerup', this.onPointerUp);
|
|
1039
|
+
viewport.removeEventListener('pointerdown', this.onPointerDown);
|
|
1040
|
+
viewport.removeEventListener('touchmove', this.noteInput);
|
|
1041
|
+
viewport.removeEventListener('focusin', this.onFocusIn);
|
|
1042
|
+
doc.removeEventListener('keydown', this.onScrollKey);
|
|
1043
|
+
window.removeEventListener('pointerup', this.onPointerRelease);
|
|
1044
|
+
window.removeEventListener('pointercancel', this.onPointerRelease);
|
|
1045
|
+
observer?.disconnect();
|
|
1046
|
+
this.viewport = undefined;
|
|
1047
|
+
this.content = undefined;
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
<template>
|
|
1052
|
+
<div
|
|
1053
|
+
class='pretui-stick-to-bottom'
|
|
1054
|
+
{{customProperty '--pretui-recede-opacity' this.recedeOpacity}}
|
|
1055
|
+
data-holding={{if this.holding 'true'}}
|
|
1056
|
+
data-following={{if this.following 'true' 'false'}}
|
|
1057
|
+
data-test-pretui-stick-to-bottom
|
|
1058
|
+
...attributes
|
|
1059
|
+
>
|
|
1060
|
+
<div
|
|
1061
|
+
class='pretui-stick-to-bottom-viewport'
|
|
1062
|
+
role={{this.role}}
|
|
1063
|
+
aria-label={{@label}}
|
|
1064
|
+
aria-busy={{if this.anchoring 'true'}}
|
|
1065
|
+
data-following={{if this.following 'true' 'false'}}
|
|
1066
|
+
data-test-pretui-stick-to-bottom-viewport
|
|
1067
|
+
{{follow this}}
|
|
1068
|
+
{{watch this.activeChanged @active}}
|
|
1069
|
+
{{watch this.followingArgChanged @following}}
|
|
1070
|
+
{{watch this.revisionChanged @revision}}
|
|
1071
|
+
>
|
|
1072
|
+
<div
|
|
1073
|
+
class='pretui-stick-to-bottom-content'
|
|
1074
|
+
data-stick-to-bottom-content
|
|
1075
|
+
data-test-pretui-stick-to-bottom-content
|
|
1076
|
+
>
|
|
1077
|
+
{{yield this.api}}
|
|
1078
|
+
</div>
|
|
1079
|
+
</div>
|
|
1080
|
+
<div class='pretui-stick-to-bottom-edge' aria-hidden='true'></div>
|
|
1081
|
+
{{#if (has-block 'overlay')}}
|
|
1082
|
+
<div
|
|
1083
|
+
class='pretui-stick-to-bottom-overlay'
|
|
1084
|
+
data-test-pretui-stick-to-bottom-overlay
|
|
1085
|
+
>
|
|
1086
|
+
{{yield this.api to='overlay'}}
|
|
1087
|
+
</div>
|
|
1088
|
+
{{/if}}
|
|
1089
|
+
</div>
|
|
1090
|
+
<style scoped>
|
|
1091
|
+
@layer PretComponent {
|
|
1092
|
+
.pretui-stick-to-bottom {
|
|
1093
|
+
position: relative;
|
|
1094
|
+
display: flex;
|
|
1095
|
+
flex-direction: column;
|
|
1096
|
+
min-height: 0;
|
|
1097
|
+
height: 100%;
|
|
1098
|
+
}
|
|
1099
|
+
.pretui-stick-to-bottom-viewport {
|
|
1100
|
+
flex: 1 1 auto;
|
|
1101
|
+
min-height: 0;
|
|
1102
|
+
overflow-x: hidden;
|
|
1103
|
+
overflow-y: auto;
|
|
1104
|
+
overscroll-behavior: contain;
|
|
1105
|
+
scrollbar-width: thin;
|
|
1106
|
+
scrollbar-gutter: stable;
|
|
1107
|
+
/* The component owns the scroll position: following, it pins
|
|
1108
|
+
the end; detached, it holds the anchor (settle). The browser
|
|
1109
|
+
must not anchor as well. */
|
|
1110
|
+
overflow-anchor: none;
|
|
1111
|
+
}
|
|
1112
|
+
.pretui-stick-to-bottom-viewport:focus-visible {
|
|
1113
|
+
outline: 2px solid var(--ring);
|
|
1114
|
+
outline-offset: -2px;
|
|
1115
|
+
}
|
|
1116
|
+
.pretui-stick-to-bottom-content {
|
|
1117
|
+
display: flow-root;
|
|
1118
|
+
}
|
|
1119
|
+
.pretui-stick-to-bottom-edge {
|
|
1120
|
+
position: absolute;
|
|
1121
|
+
inset-inline: 0;
|
|
1122
|
+
inset-block-start: 0;
|
|
1123
|
+
block-size: var(--pretui-stick-to-bottom-fade, var(--boxel-sp-lg));
|
|
1124
|
+
pointer-events: none;
|
|
1125
|
+
opacity: 0;
|
|
1126
|
+
background: linear-gradient(
|
|
1127
|
+
to bottom,
|
|
1128
|
+
var(--pretui-stick-to-bottom-ground, var(--card)),
|
|
1129
|
+
transparent
|
|
1130
|
+
);
|
|
1131
|
+
transition: opacity 140ms linear;
|
|
1132
|
+
}
|
|
1133
|
+
.pretui-stick-to-bottom-viewport[data-start-y='clipped']
|
|
1134
|
+
~ .pretui-stick-to-bottom-edge {
|
|
1135
|
+
opacity: 1;
|
|
1136
|
+
}
|
|
1137
|
+
/* The overlay adds no box: its children position against the root,
|
|
1138
|
+
which is what JumpChip expects of its nearest positioned ancestor. */
|
|
1139
|
+
.pretui-stick-to-bottom-overlay {
|
|
1140
|
+
display: contents;
|
|
1141
|
+
}
|
|
1142
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1143
|
+
.pretui-stick-to-bottom-edge {
|
|
1144
|
+
transition: none;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
</style>
|
|
1149
|
+
</template>
|
|
1150
|
+
}
|