restore most bottom tile in VP on any list change
and tell view model visible range so it can load more or fill gaps, ...
This commit is contained in:
parent
ad4ec5f04c
commit
c78a83d398
4 changed files with 108 additions and 145 deletions
|
@ -40,37 +40,14 @@ export class TimelineViewModel extends ViewModel {
|
||||||
const {timeline, tilesCreator} = options;
|
const {timeline, tilesCreator} = options;
|
||||||
this._timeline = this.track(timeline);
|
this._timeline = this.track(timeline);
|
||||||
this._tiles = new TilesCollection(timeline.entries, tilesCreator);
|
this._tiles = new TilesCollection(timeline.entries, tilesCreator);
|
||||||
|
this._timeline.loadAtTop(50);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
setVisibleTileRange(idx, len) {
|
||||||
* @return {bool} startReached if the start of the timeline was reached
|
console.log("setVisibleTileRange", idx, len);
|
||||||
*/
|
if (idx < 5) {
|
||||||
async loadAtTop() {
|
this._timeline.loadAtTop(10);
|
||||||
if (this.isDisposed) {
|
|
||||||
// stop loading more, we switched room
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
const firstTile = this._tiles.getFirst();
|
|
||||||
if (firstTile?.shape === "gap") {
|
|
||||||
return await firstTile.fill();
|
|
||||||
} else {
|
|
||||||
const topReached = await this._timeline.loadAtTop(10);
|
|
||||||
return topReached;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
unloadAtTop(/*tileAmount*/) {
|
|
||||||
// get lowerSortKey for tile at index tileAmount - 1
|
|
||||||
// tell timeline to unload till there (included given key)
|
|
||||||
}
|
|
||||||
|
|
||||||
loadAtBottom() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
unloadAtBottom(/*tileAmount*/) {
|
|
||||||
// get upperSortKey for tile at index tiles.length - tileAmount
|
|
||||||
// tell timeline to unload till there (included given key)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get tiles() {
|
get tiles() {
|
||||||
|
|
|
@ -15,9 +15,14 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
.RoomView_body > ul {
|
.RoomView_body > .Timeline {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.RoomView_body > .Timeline > ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
|
@ -138,28 +138,22 @@ export class ListView<T, V extends UIView> implements UIView {
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onAdd(idx: number, value: T) {
|
protected onAdd(idx: number, value: T) {
|
||||||
this.onBeforeListChanged();
|
|
||||||
const child = this._childCreator(value);
|
const child = this._childCreator(value);
|
||||||
this._childInstances!.splice(idx, 0, child);
|
this._childInstances!.splice(idx, 0, child);
|
||||||
insertAt(this._root!, idx, mountView(child, this._mountArgs));
|
insertAt(this._root!, idx, mountView(child, this._mountArgs));
|
||||||
this.onListChanged();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onRemove(idx: number, value: T) {
|
protected onRemove(idx: number, value: T) {
|
||||||
this.onBeforeListChanged();
|
|
||||||
const [child] = this._childInstances!.splice(idx, 1);
|
const [child] = this._childInstances!.splice(idx, 1);
|
||||||
child.root().remove();
|
child.root().remove();
|
||||||
child.unmount();
|
child.unmount();
|
||||||
this.onListChanged();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onMove(fromIdx: number, toIdx: number, value: T) {
|
protected onMove(fromIdx: number, toIdx: number, value: T) {
|
||||||
this.onBeforeListChanged();
|
|
||||||
const [child] = this._childInstances!.splice(fromIdx, 1);
|
const [child] = this._childInstances!.splice(fromIdx, 1);
|
||||||
this._childInstances!.splice(toIdx, 0, child);
|
this._childInstances!.splice(toIdx, 0, child);
|
||||||
child.root().remove();
|
child.root().remove();
|
||||||
insertAt(this._root!, toIdx, child.root());
|
insertAt(this._root!, toIdx, child.root());
|
||||||
this.onListChanged();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onUpdate(i: number, value: T, params: any) {
|
protected onUpdate(i: number, value: T, params: any) {
|
||||||
|
@ -182,9 +176,6 @@ export class ListView<T, V extends UIView> implements UIView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onBeforeListChanged() {}
|
|
||||||
protected onListChanged() {}
|
|
||||||
|
|
||||||
protected getChildInstanceByIndex(idx: number): V | undefined {
|
protected getChildInstanceByIndex(idx: number): V | undefined {
|
||||||
return this._childInstances?.[idx];
|
return this._childInstances?.[idx];
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,6 +15,7 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {ListView} from "../../general/ListView";
|
import {ListView} from "../../general/ListView";
|
||||||
|
import {TemplateView, TemplateBuilder} from "../../general/TemplateView.js";
|
||||||
import {GapView} from "./timeline/GapView.js";
|
import {GapView} from "./timeline/GapView.js";
|
||||||
import {TextMessageView} from "./timeline/TextMessageView.js";
|
import {TextMessageView} from "./timeline/TextMessageView.js";
|
||||||
import {ImageView} from "./timeline/ImageView.js";
|
import {ImageView} from "./timeline/ImageView.js";
|
||||||
|
@ -25,6 +26,7 @@ import {AnnouncementView} from "./timeline/AnnouncementView.js";
|
||||||
import {RedactedView} from "./timeline/RedactedView.js";
|
import {RedactedView} from "./timeline/RedactedView.js";
|
||||||
import {SimpleTile} from "../../../../../domain/session/room/timeline/tiles/SimpleTile.js";
|
import {SimpleTile} from "../../../../../domain/session/room/timeline/tiles/SimpleTile.js";
|
||||||
import {TimelineViewModel} from "../../../../../domain/session/room/timeline/TimelineViewModel.js";
|
import {TimelineViewModel} from "../../../../../domain/session/room/timeline/TimelineViewModel.js";
|
||||||
|
import {BaseObservableList as ObservableList} from "../../../../../../observable/list/BaseObservableList.js";
|
||||||
|
|
||||||
type TileView = GapView | AnnouncementView | TextMessageView |
|
type TileView = GapView | AnnouncementView | TextMessageView |
|
||||||
ImageView | VideoView | FileView | MissingAttachmentView | RedactedView;
|
ImageView | VideoView | FileView | MissingAttachmentView | RedactedView;
|
||||||
|
@ -46,16 +48,86 @@ export function viewClassForEntry(entry: SimpleTile): TileViewConstructor | unde
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TimelineView extends ListView<SimpleTile, TileView> {
|
function bottom(node: HTMLElement): number {
|
||||||
|
return node.offsetTop + node.clientHeight;
|
||||||
|
}
|
||||||
|
|
||||||
private _atBottom: boolean;
|
function findFirstNodeIndexAtOrBelow(tiles: HTMLElement, top: number, startIndex: number = (tiles.children.length - 1)): number {
|
||||||
private _topLoadingPromise?: Promise<boolean>;
|
for (var i = startIndex; i >= 0; i--) {
|
||||||
private _viewModel: TimelineViewModel;
|
const node = tiles.children[i] as HTMLElement;
|
||||||
|
if (node.offsetTop < top) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
constructor(viewModel: TimelineViewModel) {
|
export class TimelineView extends TemplateView<TimelineViewModel> {
|
||||||
|
|
||||||
|
private anchoredNode?: HTMLElement;
|
||||||
|
private anchoredBottom: number = 0;
|
||||||
|
private stickToBottom: boolean = true;
|
||||||
|
|
||||||
|
render(t: TemplateBuilder, vm: TimelineViewModel) {
|
||||||
|
return t.div({className: "Timeline bottom-aligned-scroll", onScroll: () => this.onScroll()}, [
|
||||||
|
t.view(new TilesListView(vm.tiles, () => this._restoreScrollPosition()))
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _restoreScrollPosition() {
|
||||||
|
const timeline = this.root() as HTMLElement;
|
||||||
|
const tiles = timeline.firstElementChild as HTMLElement;
|
||||||
|
|
||||||
|
const missingTilesHeight = timeline.clientHeight - tiles.clientHeight;
|
||||||
|
if (missingTilesHeight > 0) {
|
||||||
|
tiles.style.setProperty("margin-top", `${missingTilesHeight}px`);
|
||||||
|
} else {
|
||||||
|
tiles.style.removeProperty("margin-top");
|
||||||
|
if (this.stickToBottom) {
|
||||||
|
timeline.scrollTop = timeline.scrollHeight;
|
||||||
|
} else if (this.anchoredNode) {
|
||||||
|
const newAnchoredBottom = bottom(this.anchoredNode!);
|
||||||
|
if (newAnchoredBottom !== this.anchoredBottom) {
|
||||||
|
const bottomDiff = newAnchoredBottom - this.anchoredBottom;
|
||||||
|
console.log(`restore: scroll by ${bottomDiff} as height changed`);
|
||||||
|
timeline.scrollBy(0, bottomDiff);
|
||||||
|
this.anchoredBottom = newAnchoredBottom;
|
||||||
|
} else {
|
||||||
|
console.log("restore: bottom didn't change, must be below viewport");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onScroll(): void {
|
||||||
|
const timeline = this.root() as HTMLElement;
|
||||||
|
const {scrollHeight, scrollTop, clientHeight} = timeline;
|
||||||
|
const tiles = timeline.firstElementChild as HTMLElement;
|
||||||
|
|
||||||
|
this.stickToBottom = Math.abs(scrollHeight - (scrollTop + clientHeight)) < 5;
|
||||||
|
if (!this.stickToBottom) {
|
||||||
|
// save bottom node position
|
||||||
|
const viewportBottom = scrollTop + clientHeight;
|
||||||
|
const anchoredNodeIndex = findFirstNodeIndexAtOrBelow(tiles, viewportBottom);
|
||||||
|
let topNodeIndex = findFirstNodeIndexAtOrBelow(tiles, scrollTop, anchoredNodeIndex);
|
||||||
|
if (topNodeIndex === -1) {
|
||||||
|
topNodeIndex = 0;
|
||||||
|
}
|
||||||
|
this.anchoredNode = tiles.childNodes[anchoredNodeIndex] as HTMLElement;
|
||||||
|
this.anchoredNode.classList.add("pinned");
|
||||||
|
this.anchoredBottom = bottom(this.anchoredNode!);
|
||||||
|
this.value.setVisibleTileRange(topNodeIndex, anchoredNodeIndex - topNodeIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TilesListView extends ListView<SimpleTile, TileView> {
|
||||||
|
|
||||||
|
private onChanged: () => void;
|
||||||
|
|
||||||
|
constructor(tiles: ObservableList<SimpleTile>, onChanged: () => void) {
|
||||||
const options = {
|
const options = {
|
||||||
className: "Timeline bottom-aligned-scroll",
|
list: tiles,
|
||||||
list: viewModel.tiles,
|
|
||||||
onItemClick: (tileView, evt) => tileView.onClick(evt),
|
onItemClick: (tileView, evt) => tileView.onClick(evt),
|
||||||
};
|
};
|
||||||
super(options, entry => {
|
super(options, entry => {
|
||||||
|
@ -64,108 +136,10 @@ export class TimelineView extends ListView<SimpleTile, TileView> {
|
||||||
return new View(entry);
|
return new View(entry);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this._atBottom = false;
|
this.onChanged = onChanged;
|
||||||
this._topLoadingPromise = undefined;
|
|
||||||
this._viewModel = viewModel;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override handleEvent(evt: Event) {
|
protected onUpdate(index: number, value: SimpleTile, param: any) {
|
||||||
if (evt.type === "scroll") {
|
|
||||||
this._handleScroll(evt);
|
|
||||||
} else {
|
|
||||||
super.handleEvent(evt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async _loadAtTopWhile(predicate: () => boolean) {
|
|
||||||
if (this._topLoadingPromise) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
while (predicate()) {
|
|
||||||
// fill, not enough content to fill timeline
|
|
||||||
this._topLoadingPromise = this._viewModel.loadAtTop();
|
|
||||||
const shouldStop = await this._topLoadingPromise;
|
|
||||||
if (shouldStop) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
//ignore error, as it is handled in the VM
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
this._topLoadingPromise = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async _handleScroll(evt: Event) {
|
|
||||||
const PAGINATE_OFFSET = 100;
|
|
||||||
const root = this.root();
|
|
||||||
if (root.scrollTop < PAGINATE_OFFSET && !this._topLoadingPromise && this._viewModel) {
|
|
||||||
// to calculate total amountGrown to check when we stop loading
|
|
||||||
let beforeContentHeight = root.scrollHeight;
|
|
||||||
// to adjust scrollTop every time
|
|
||||||
let lastContentHeight = beforeContentHeight;
|
|
||||||
// load until pagination offset is reached again
|
|
||||||
this._loadAtTopWhile(() => {
|
|
||||||
const contentHeight = root.scrollHeight;
|
|
||||||
const amountGrown = contentHeight - beforeContentHeight;
|
|
||||||
const topDiff = contentHeight - lastContentHeight;
|
|
||||||
root.scrollBy(0, topDiff);
|
|
||||||
lastContentHeight = contentHeight;
|
|
||||||
return amountGrown < PAGINATE_OFFSET;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override mount() {
|
|
||||||
const root = super.mount();
|
|
||||||
root.addEventListener("scroll", this);
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
override unmount() {
|
|
||||||
this.root().removeEventListener("scroll", this);
|
|
||||||
super.unmount();
|
|
||||||
}
|
|
||||||
|
|
||||||
override async loadList() {
|
|
||||||
super.loadList();
|
|
||||||
const root = this.root();
|
|
||||||
// yield so the browser can render the list
|
|
||||||
// and we can measure the content below
|
|
||||||
await Promise.resolve();
|
|
||||||
const {scrollHeight, clientHeight} = root;
|
|
||||||
if (scrollHeight > clientHeight) {
|
|
||||||
root.scrollTop = root.scrollHeight;
|
|
||||||
}
|
|
||||||
// load while viewport is not filled
|
|
||||||
this._loadAtTopWhile(() => {
|
|
||||||
const {scrollHeight, clientHeight} = root;
|
|
||||||
return scrollHeight <= clientHeight;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
override onBeforeListChanged() {
|
|
||||||
const fromBottom = this._distanceFromBottom();
|
|
||||||
this._atBottom = fromBottom < 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
_distanceFromBottom() {
|
|
||||||
const root = this.root();
|
|
||||||
return root.scrollHeight - root.scrollTop - root.clientHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
override onListChanged() {
|
|
||||||
const root = this.root();
|
|
||||||
if (this._atBottom) {
|
|
||||||
root.scrollTop = root.scrollHeight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override onUpdate(index: number, value: SimpleTile, param: any) {
|
|
||||||
if (param === "shape") {
|
if (param === "shape") {
|
||||||
const ExpectedClass = viewClassForEntry(value);
|
const ExpectedClass = viewClassForEntry(value);
|
||||||
const child = this.getChildInstanceByIndex(index);
|
const child = this.getChildInstanceByIndex(index);
|
||||||
|
@ -178,5 +152,21 @@ export class TimelineView extends ListView<SimpleTile, TileView> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
super.onUpdate(index, value, param);
|
super.onUpdate(index, value, param);
|
||||||
|
this.onChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onAdd(idx: number, value: SimpleTile) {
|
||||||
|
super.onAdd(idx, value);
|
||||||
|
this.onChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onRemove(idx: number, value: SimpleTile) {
|
||||||
|
super.onRemove(idx, value);
|
||||||
|
this.onChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onMove(fromIdx: number, toIdx: number, value: SimpleTile) {
|
||||||
|
super.onMove(fromIdx, toIdx, value);
|
||||||
|
this.onChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Reference in a new issue