import {Column, ColumnView} from "./column" import {ModelEvent, server_event} from "@bokehjs/core/bokeh_events" import type * as p from "@bokehjs/core/properties" import type {Attrs} from "@bokehjs/core/types" import {build_views} from "@bokehjs/core/build_views" import type {UIElementView} from "@bokehjs/models/ui/ui_element" import {ColumnView as BkColumnView} from "@bokehjs/models/layouts/column" @server_event("scroll_latest_event") export class ScrollLatestEvent extends ModelEvent { constructor(readonly model: Feed, readonly rerender: boolean) { super() this.origin = model this.rerender = rerender } protected override get event_values(): Attrs { return {model: this.origin, rerender: this.rerender} } static override from_values(values: object) { const {model, rerender} = values as {model: Feed, rerender: boolean} return new ScrollLatestEvent(model, rerender) } } export class FeedView extends ColumnView { declare model: Feed _intersection_observer: IntersectionObserver _last_visible: UIElementView | null _rendered: boolean = false _sync: boolean _reference: number | null = null _reference_view: UIElementView | null = null override initialize(): void { super.initialize() this._sync = true this._intersection_observer = new IntersectionObserver((entries) => { const visible = [...this.model.visible_children] const nodes = this.node_map for (const entry of entries) { const id = nodes.get(entry.target).id if (entry.isIntersecting) { if (!visible.includes(id)) { visible.push(id) } } else if (visible.includes(id)) { visible.splice(visible.indexOf(id), 1) } } if (this._sync) { this.model.visible_children = visible } if (visible.length > 0) { const refs = this.child_models.map((model) => model.id) const indices = visible.map((ref) => refs.indexOf(ref)) this._last_visible = this.child_views[Math.min(...indices)] } else { this._last_visible = null } }, { root: this.el, threshold: 0.01, }) } override connect_signals(): void { super.connect_signals() this.model.on_event(ScrollLatestEvent, (event: ScrollLatestEvent) => { this.scroll_to_latest() if (event.rerender) { this._rendered = false } }) } get node_map(): any { const nodes = new Map() for (const view of this.child_views) { nodes.set(view.el, view.model) } return nodes } override async update_children(): Promise { const last = this._last_visible const scroll_top = this.el.scrollTop this._reference_view = last this._reference = last?.el.offsetTop || 0 this._sync = false const created = await this.build_child_views() const created_children = new Set(created) const createdLength = created.length const views_length = this.child_views.length // Check whether we simply have to prepend or append items // instead of removing and reordering them const is_prepended = created.every((view, index) => view === this.child_views[index]) const is_appended = created.every((view, index) => view === this.child_views[views_length - createdLength + index]) const reorder = !(is_prepended || is_appended) if (reorder) { // First remove and then either reattach existing elements or render and // attach new elements, so that the order of children is consistent, while // avoiding expensive re-rendering of existing views. for (const child_view of this.child_views) { child_view.el.remove() } } const prepend: Element[] = [] for (const child_view of this.child_views) { const is_new = created_children.has(child_view) const target = this.shadow_el if (reorder) { if (is_new) { child_view.render_to(target) } else { target.append(child_view.el) } } else { if (is_new) { child_view.render() if (is_appended) { target.append(child_view.el) } else if (is_prepended) { prepend.push(child_view.el) } } } } if (is_prepended) { this.shadow_el.prepend(...prepend) } this.r_after_render() this._update_children() this.invalidate_layout() this._sync = true // Ensure we adjust the scroll position in case we prepended items if (is_prepended) { requestAnimationFrame(() => { const after_offset = this._reference_view?.el.offsetTop || 0 const offset = (after_offset-(this._reference || 0)) this.el.scrollTo({top: scroll_top + offset, behavior: "smooth"}) }) } } override async build_child_views(): Promise { const {created, removed} = await build_views(this._child_views, this.child_models, {parent: this}) const visible = this.model.visible_children for (const view of removed) { if (visible.includes(view.model.id)) { visible.splice(visible.indexOf(view.model.id), 1) } this._resize_observer.unobserve(view.el) this._intersection_observer.unobserve(view.el) } this.model.visible_children = [...visible] for (const view of created) { this._resize_observer.observe(view.el, {box: "border-box"}) this._intersection_observer.observe(view.el) } return created } override render(): void { this._rendered = false super.render() } override trigger_auto_scroll(): void {} override after_render(): void { BkColumnView.prototype.after_render.call(this) requestAnimationFrame(() => { if (this.model.scroll_position) { this.scroll_to_position() } if (this.model.view_latest && !this._rendered) { this.scroll_to_latest() } this.toggle_scroll_button() this._rendered = true }) } } export namespace Feed { export type Attrs = p.AttrsOf export type Props = Column.Props & { visible_children: p.Property } } export interface Feed extends Feed.Attrs { } export class Feed extends Column { declare properties: Feed.Props constructor(attrs?: Partial) { super(attrs) } static override __module__ = "panel.models.feed" static { this.prototype.default_view = FeedView this.define(({List, Str}) => ({ visible_children: [List(Str), []], })) } }