summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/pages/session/scroll-spy.ts
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-06 09:37:49 -0600
committerGitHub <[email protected]>2026-02-06 09:37:49 -0600
commita4bc883595df9ea0f752079519081bc602408553 (patch)
tree583f21642f431899abe1dfb1f6bd9b2c01dc0206 /packages/app/src/pages/session/scroll-spy.ts
parentc07077f96c0019b2e18e0e8e1e0383deda08b3e6 (diff)
downloadopencode-a4bc883595df9ea0f752079519081bc602408553.tar.gz
opencode-a4bc883595df9ea0f752079519081bc602408553.zip
chore: refactoring and tests (#12468)
Diffstat (limited to 'packages/app/src/pages/session/scroll-spy.ts')
-rw-r--r--packages/app/src/pages/session/scroll-spy.ts274
1 files changed, 274 insertions, 0 deletions
diff --git a/packages/app/src/pages/session/scroll-spy.ts b/packages/app/src/pages/session/scroll-spy.ts
new file mode 100644
index 000000000..8c52d77dc
--- /dev/null
+++ b/packages/app/src/pages/session/scroll-spy.ts
@@ -0,0 +1,274 @@
+type Visible = {
+ id: string
+ ratio: number
+ top: number
+}
+
+type Offset = {
+ id: string
+ top: number
+}
+
+type Input = {
+ onActive: (id: string) => void
+ raf?: (cb: FrameRequestCallback) => number
+ caf?: (id: number) => void
+ IntersectionObserver?: typeof globalThis.IntersectionObserver
+ ResizeObserver?: typeof globalThis.ResizeObserver
+ MutationObserver?: typeof globalThis.MutationObserver
+}
+
+export const pickVisibleId = (list: Visible[], line: number) => {
+ if (list.length === 0) return
+
+ const sorted = [...list].sort((a, b) => {
+ if (b.ratio !== a.ratio) return b.ratio - a.ratio
+
+ const da = Math.abs(a.top - line)
+ const db = Math.abs(b.top - line)
+ if (da !== db) return da - db
+
+ return a.top - b.top
+ })
+
+ return sorted[0]?.id
+}
+
+export const pickOffsetId = (list: Offset[], cutoff: number) => {
+ if (list.length === 0) return
+
+ let lo = 0
+ let hi = list.length - 1
+ let out = 0
+
+ while (lo <= hi) {
+ const mid = (lo + hi) >> 1
+ const top = list[mid]?.top
+ if (top === undefined) break
+
+ if (top <= cutoff) {
+ out = mid
+ lo = mid + 1
+ continue
+ }
+
+ hi = mid - 1
+ }
+
+ return list[out]?.id
+}
+
+export const createScrollSpy = (input: Input) => {
+ const raf = input.raf ?? requestAnimationFrame
+ const caf = input.caf ?? cancelAnimationFrame
+ const CtorIO = input.IntersectionObserver ?? globalThis.IntersectionObserver
+ const CtorRO = input.ResizeObserver ?? globalThis.ResizeObserver
+ const CtorMO = input.MutationObserver ?? globalThis.MutationObserver
+
+ let root: HTMLDivElement | undefined
+ let io: IntersectionObserver | undefined
+ let ro: ResizeObserver | undefined
+ let mo: MutationObserver | undefined
+ let frame: number | undefined
+ let active: string | undefined
+ let dirty = true
+
+ const node = new Map<string, HTMLElement>()
+ const id = new WeakMap<HTMLElement, string>()
+ const visible = new Map<string, { ratio: number; top: number }>()
+ let offset: Offset[] = []
+
+ const schedule = () => {
+ if (frame !== undefined) return
+ frame = raf(() => {
+ frame = undefined
+ update()
+ })
+ }
+
+ const refreshOffset = () => {
+ const el = root
+ if (!el) {
+ offset = []
+ dirty = false
+ return
+ }
+
+ const base = el.getBoundingClientRect().top
+ offset = [...node].map(([next, item]) => ({
+ id: next,
+ top: item.getBoundingClientRect().top - base + el.scrollTop,
+ }))
+ offset.sort((a, b) => a.top - b.top)
+ dirty = false
+ }
+
+ const update = () => {
+ const el = root
+ if (!el) return
+
+ const line = el.getBoundingClientRect().top + 100
+ const next =
+ pickVisibleId(
+ [...visible].map(([k, v]) => ({
+ id: k,
+ ratio: v.ratio,
+ top: v.top,
+ })),
+ line,
+ ) ??
+ (() => {
+ if (dirty) refreshOffset()
+ return pickOffsetId(offset, el.scrollTop + 100)
+ })()
+
+ if (!next || next === active) return
+ active = next
+ input.onActive(next)
+ }
+
+ const observe = () => {
+ const el = root
+ if (!el) return
+
+ io?.disconnect()
+ io = undefined
+ if (CtorIO) {
+ try {
+ io = new CtorIO(
+ (entries) => {
+ for (const entry of entries) {
+ const item = entry.target
+ if (!(item instanceof HTMLElement)) continue
+ const key = id.get(item)
+ if (!key) continue
+
+ if (!entry.isIntersecting || entry.intersectionRatio <= 0) {
+ visible.delete(key)
+ continue
+ }
+
+ visible.set(key, {
+ ratio: entry.intersectionRatio,
+ top: entry.boundingClientRect.top,
+ })
+ }
+
+ schedule()
+ },
+ {
+ root: el,
+ threshold: [0, 0.25, 0.5, 0.75, 1],
+ },
+ )
+ } catch {
+ io = undefined
+ }
+ }
+
+ if (io) {
+ for (const item of node.values()) io.observe(item)
+ }
+
+ ro?.disconnect()
+ ro = undefined
+ if (CtorRO) {
+ ro = new CtorRO(() => {
+ dirty = true
+ schedule()
+ })
+ ro.observe(el)
+ for (const item of node.values()) ro.observe(item)
+ }
+
+ mo?.disconnect()
+ mo = undefined
+ if (CtorMO) {
+ mo = new CtorMO(() => {
+ dirty = true
+ schedule()
+ })
+ mo.observe(el, { subtree: true, childList: true, characterData: true })
+ }
+
+ dirty = true
+ schedule()
+ }
+
+ const setContainer = (el?: HTMLDivElement) => {
+ if (root === el) return
+
+ root = el
+ visible.clear()
+ active = undefined
+ observe()
+ }
+
+ const register = (el: HTMLElement, key: string) => {
+ const prev = node.get(key)
+ if (prev && prev !== el) {
+ io?.unobserve(prev)
+ ro?.unobserve(prev)
+ }
+
+ node.set(key, el)
+ id.set(el, key)
+ if (io) io.observe(el)
+ if (ro) ro.observe(el)
+ dirty = true
+ schedule()
+ }
+
+ const unregister = (key: string) => {
+ const item = node.get(key)
+ if (!item) return
+
+ io?.unobserve(item)
+ ro?.unobserve(item)
+ node.delete(key)
+ visible.delete(key)
+ dirty = true
+ }
+
+ const markDirty = () => {
+ dirty = true
+ schedule()
+ }
+
+ const clear = () => {
+ for (const item of node.values()) {
+ io?.unobserve(item)
+ ro?.unobserve(item)
+ }
+
+ node.clear()
+ visible.clear()
+ offset = []
+ active = undefined
+ dirty = true
+ }
+
+ const destroy = () => {
+ if (frame !== undefined) caf(frame)
+ frame = undefined
+ clear()
+ io?.disconnect()
+ ro?.disconnect()
+ mo?.disconnect()
+ io = undefined
+ ro = undefined
+ mo = undefined
+ root = undefined
+ }
+
+ return {
+ setContainer,
+ register,
+ unregister,
+ onScroll: schedule,
+ markDirty,
+ clear,
+ destroy,
+ getActiveId: () => active,
+ }
+}