diff --git a/src/platform/web/ui/general/LazyListView.js b/src/platform/web/ui/general/LazyListView.js index 4795ce5a..6f565e30 100644 --- a/src/platform/web/ui/general/LazyListView.js +++ b/src/platform/web/ui/general/LazyListView.js @@ -15,8 +15,9 @@ limitations under the License. */ import {el} from "./html.js"; -import {mountView} from "./utils.js"; -import {insertAt, ListView} from "./ListView.js"; +import {mountView} from "./utils"; +import {ListView} from "./ListView"; +import {insertAt} from "./utils"; class ItemRange { constructor(topCount, renderCount, bottomCount) { diff --git a/src/platform/web/ui/general/ListView.js b/src/platform/web/ui/general/ListView.js deleted file mode 100644 index 74aa9d87..00000000 --- a/src/platform/web/ui/general/ListView.js +++ /dev/null @@ -1,163 +0,0 @@ -/* -Copyright 2020 Bruno Windels - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import {el} from "./html.js"; -import {mountView} from "./utils.js"; - -export function insertAt(parentNode, idx, childNode) { - const isLast = idx === parentNode.childElementCount; - if (isLast) { - parentNode.appendChild(childNode); - } else { - const nextDomNode = parentNode.children[idx]; - parentNode.insertBefore(childNode, nextDomNode); - } -} - -export class ListView { - constructor({list, onItemClick, className, tagName = "ul", parentProvidesUpdates = true}, childCreator) { - this._onItemClick = onItemClick; - this._list = list; - this._className = className; - this._tagName = tagName; - this._root = null; - this._subscription = null; - this._childCreator = childCreator; - this._childInstances = null; - this._mountArgs = {parentProvidesUpdates}; - this._onClick = this._onClick.bind(this); - } - - root() { - return this._root; - } - - update(attributes) { - if (attributes.hasOwnProperty("list")) { - if (this._subscription) { - this._unloadList(); - while (this._root.lastChild) { - this._root.lastChild.remove(); - } - } - this._list = attributes.list; - this.loadList(); - } - } - - mount() { - const attr = {}; - if (this._className) { - attr.className = this._className; - } - this._root = el(this._tagName, attr); - this.loadList(); - if (this._onItemClick) { - this._root.addEventListener("click", this._onClick); - } - return this._root; - } - - unmount() { - if (this._list) { - this._unloadList(); - } - } - - _onClick(event) { - if (event.target === this._root) { - return; - } - let childNode = event.target; - while (childNode.parentNode !== this._root) { - childNode = childNode.parentNode; - } - const index = Array.prototype.indexOf.call(this._root.childNodes, childNode); - const childView = this._childInstances[index]; - this._onItemClick(childView, event); - } - - _unloadList() { - this._subscription = this._subscription(); - for (let child of this._childInstances) { - child.unmount(); - } - this._childInstances = null; - } - - loadList() { - if (!this._list) { - return; - } - this._subscription = this._list.subscribe(this); - this._childInstances = []; - const fragment = document.createDocumentFragment(); - for (let item of this._list) { - const child = this._childCreator(item); - this._childInstances.push(child); - fragment.appendChild(mountView(child, this._mountArgs)); - } - this._root.appendChild(fragment); - } - - onAdd(idx, value) { - this.onBeforeListChanged(); - const child = this._childCreator(value); - this._childInstances.splice(idx, 0, child); - insertAt(this._root, idx, mountView(child, this._mountArgs)); - this.onListChanged(); - } - - onRemove(idx/*, _value*/) { - this.onBeforeListChanged(); - const [child] = this._childInstances.splice(idx, 1); - child.root().remove(); - child.unmount(); - this.onListChanged(); - } - - onMove(fromIdx, toIdx/*, value*/) { - this.onBeforeListChanged(); - const [child] = this._childInstances.splice(fromIdx, 1); - this._childInstances.splice(toIdx, 0, child); - child.root().remove(); - insertAt(this._root, toIdx, child.root()); - this.onListChanged(); - } - - onUpdate(i, value, params) { - if (this._childInstances) { - const instance = this._childInstances[i]; - instance && instance.update(value, params); - } - } - - recreateItem(index, value) { - if (this._childInstances) { - const child = this._childCreator(value); - if (!child) { - this.onRemove(index, value); - } else { - const [oldChild] = this._childInstances.splice(index, 1, child); - this._root.replaceChild(child.mount(this._mountArgs), oldChild.root()); - oldChild.unmount(); - } - } - } - - onBeforeListChanged() {} - onListChanged() {} -} diff --git a/src/platform/web/ui/general/ListView.ts b/src/platform/web/ui/general/ListView.ts new file mode 100644 index 00000000..049ad2a7 --- /dev/null +++ b/src/platform/web/ui/general/ListView.ts @@ -0,0 +1,187 @@ +/* +Copyright 2020 Bruno Windels + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {el} from "./html.js"; +import {mountView, insertAt} from "./utils"; +import {BaseObservableList as ObservableList} from "../../../../observable/list/BaseObservableList.js"; +import {UIView, IMountArgs} from "./types"; + +interface IOptions { + list: ObservableList, + onItemClick?: (childView: V, evt: UIEvent) => void, + className?: string, + tagName?: string, + parentProvidesUpdates: boolean +} + +type SubscriptionHandle = () => undefined; + +export class ListView implements UIView { + + private _onItemClick?: (childView: V, evt: UIEvent) => void; + private _list: ObservableList; + private _className?: string; + private _tagName: string; + private _root?: HTMLElement; + private _subscription?: SubscriptionHandle; + private _childCreator: (value: T) => V; + private _childInstances?: V[]; + private _mountArgs: IMountArgs; + + constructor( + {list, onItemClick, className, tagName = "ul", parentProvidesUpdates = true}: IOptions, + childCreator: (value: T) => V + ) { + this._onItemClick = onItemClick; + this._list = list; + this._className = className; + this._tagName = tagName; + this._root = undefined; + this._subscription = undefined; + this._childCreator = childCreator; + this._childInstances = undefined; + this._mountArgs = {parentProvidesUpdates}; + } + + root(): HTMLElement { + // won't be undefined when called between mount and unmount + return this._root!; + } + + update(attributes: IOptions) { + if (attributes.list) { + if (this._subscription) { + this._unloadList(); + while (this._root!.lastChild) { + this._root!.lastChild.remove(); + } + } + this._list = attributes.list; + this.loadList(); + } + } + + mount(): HTMLElement { + const attr: {[name: string]: any} = {}; + if (this._className) { + attr.className = this._className; + } + this._root = el(this._tagName, attr); + this.loadList(); + if (this._onItemClick) { + this._root!.addEventListener("click", this); + } + return this._root!; + } + + handleEvent(evt: Event) { + if (evt.type === "click") { + this._handleClick(evt as UIEvent); + } + } + + unmount(): void { + if (this._list) { + this._unloadList(); + } + } + + private _handleClick(event: UIEvent) { + if (event.target === this._root || !this._onItemClick) { + return; + } + let childNode = event.target as Element; + while (childNode.parentNode !== this._root) { + childNode = childNode.parentNode as Element; + } + const index = Array.prototype.indexOf.call(this._root!.childNodes, childNode); + const childView = this._childInstances![index]; + if (childView) { + this._onItemClick(childView, event); + } + } + + private _unloadList() { + this._subscription = this._subscription!(); + for (let child of this._childInstances!) { + child.unmount(); + } + this._childInstances = undefined; + } + + private loadList() { + if (!this._list) { + return; + } + this._subscription = this._list.subscribe(this); + this._childInstances = []; + const fragment = document.createDocumentFragment(); + for (let item of this._list) { + const child = this._childCreator(item); + this._childInstances!.push(child); + fragment.appendChild(mountView(child, this._mountArgs)); + } + this._root!.appendChild(fragment); + } + + protected onAdd(idx: number, value: T) { + this.onBeforeListChanged(); + const child = this._childCreator(value); + this._childInstances!.splice(idx, 0, child); + insertAt(this._root!, idx, mountView(child, this._mountArgs)); + this.onListChanged(); + } + + protected onRemove(idx: number, value: T) { + this.onBeforeListChanged(); + const [child] = this._childInstances!.splice(idx, 1); + child.root().remove(); + child.unmount(); + this.onListChanged(); + } + + protected onMove(fromIdx: number, toIdx: number, value: T) { + this.onBeforeListChanged(); + const [child] = this._childInstances!.splice(fromIdx, 1); + this._childInstances!.splice(toIdx, 0, child); + child.root().remove(); + insertAt(this._root!, toIdx, child.root()); + this.onListChanged(); + } + + protected onUpdate(i: number, value: T, params: any) { + if (this._childInstances) { + const instance = this._childInstances![i]; + instance && instance.update(value, params); + } + } + + protected recreateItem(index: number, value: T) { + if (this._childInstances) { + const child = this._childCreator(value); + if (!child) { + this.onRemove(index, value); + } else { + const [oldChild] = this._childInstances!.splice(index, 1, child); + this._root!.replaceChild(child.mount(this._mountArgs), oldChild.root()); + oldChild.unmount(); + } + } + } + + protected onBeforeListChanged() {} + protected onListChanged() {} +} diff --git a/src/platform/web/ui/general/TemplateView.js b/src/platform/web/ui/general/TemplateView.js index 4b2bcf74..3e9727dc 100644 --- a/src/platform/web/ui/general/TemplateView.js +++ b/src/platform/web/ui/general/TemplateView.js @@ -15,7 +15,7 @@ limitations under the License. */ import { setAttribute, text, isChildren, classNames, TAG_NAMES, HTML_NS } from "./html.js"; -import {mountView} from "./utils.js"; +import {mountView} from "./utils"; import {BaseUpdateView} from "./BaseUpdateView.js"; function objHasFns(obj) { diff --git a/src/platform/web/ui/general/utils.js b/src/platform/web/ui/general/types.ts similarity index 58% rename from src/platform/web/ui/general/utils.js rename to src/platform/web/ui/general/types.ts index d74de690..f8f671c7 100644 --- a/src/platform/web/ui/general/utils.js +++ b/src/platform/web/ui/general/types.ts @@ -13,15 +13,14 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ +export interface IMountArgs { + // if true, the parent will call update() rather than the view updating itself by binding to a data source. + parentProvidesUpdates: boolean +}; -import {errorToDOM} from "./error.js"; - -export function mountView(view, mountArgs = undefined) { - let node; - try { - node = view.mount(mountArgs); - } catch (err) { - node = errorToDOM(err); - } - return node; -} \ No newline at end of file +export interface UIView { + mount(args?: IMountArgs): HTMLElement; + root(): HTMLElement; // should only be called between mount() and unmount() + unmount(): void; + update(...any); // this isn't really standarized yet +} diff --git a/src/platform/web/ui/general/error.js b/src/platform/web/ui/general/utils.ts similarity index 53% rename from src/platform/web/ui/general/error.js rename to src/platform/web/ui/general/utils.ts index 48728a4b..f4469ca1 100644 --- a/src/platform/web/ui/general/error.js +++ b/src/platform/web/ui/general/utils.ts @@ -1,5 +1,5 @@ /* -Copyright 2020 Bruno Windels +Copyright 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,11 +14,22 @@ See the License for the specific language governing permissions and limitations under the License. */ +import {UIView, IMountArgs} from "./types"; import {tag} from "./html.js"; -export function errorToDOM(error) { +export function mountView(view: UIView, mountArgs: IMountArgs): HTMLElement { + let node; + try { + node = view.mount(mountArgs); + } catch (err) { + node = errorToDOM(err); + } + return node; +} + +export function errorToDOM(error: Error): HTMLElement { const stack = new Error().stack; - let callee = null; + let callee: string | null = null; if (stack) { callee = stack.split("\n")[1]; } @@ -29,3 +40,13 @@ export function errorToDOM(error) { tag.pre(error.stack), ]); } + +export function insertAt(parentNode: HTMLElement, idx: number, childNode: HTMLElement): void { + const isLast = idx === parentNode.childElementCount; + if (isLast) { + parentNode.appendChild(childNode); + } else { + const nextDomNode = parentNode.children[idx]; + parentNode.insertBefore(childNode, nextDomNode); + } +} diff --git a/src/platform/web/ui/login/SessionPickerView.js b/src/platform/web/ui/login/SessionPickerView.js index 4bb69ee2..a4feea17 100644 --- a/src/platform/web/ui/login/SessionPickerView.js +++ b/src/platform/web/ui/login/SessionPickerView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ListView} from "../general/ListView.js"; +import {ListView} from "../general/ListView"; import {TemplateView} from "../general/TemplateView.js"; import {hydrogenGithubLink} from "./common.js"; import {SessionLoadStatusView} from "./SessionLoadStatusView.js"; diff --git a/src/platform/web/ui/session/leftpanel/LeftPanelView.js b/src/platform/web/ui/session/leftpanel/LeftPanelView.js index 8757ad42..d8f21aa2 100644 --- a/src/platform/web/ui/session/leftpanel/LeftPanelView.js +++ b/src/platform/web/ui/session/leftpanel/LeftPanelView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ListView} from "../../general/ListView.js"; +import {ListView} from "../../general/ListView"; import {TemplateView} from "../../general/TemplateView.js"; import {RoomTileView} from "./RoomTileView.js"; import {InviteTileView} from "./InviteTileView.js"; diff --git a/src/platform/web/ui/session/room/TimelineView.js b/src/platform/web/ui/session/room/TimelineView.js index 2bac6c1b..4b2601ce 100644 --- a/src/platform/web/ui/session/room/TimelineView.js +++ b/src/platform/web/ui/session/room/TimelineView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ListView} from "../../general/ListView.js"; +import {ListView} from "../../general/ListView"; import {GapView} from "./timeline/GapView.js"; import {TextMessageView} from "./timeline/TextMessageView.js"; import {ImageView} from "./timeline/ImageView.js"; diff --git a/src/platform/web/ui/session/room/timeline/BaseMessageView.js b/src/platform/web/ui/session/room/timeline/BaseMessageView.js index fb797f98..9469d201 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMessageView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMessageView.js @@ -17,7 +17,7 @@ limitations under the License. import {renderStaticAvatar} from "../../../avatar.js"; import {tag} from "../../../general/html.js"; -import {mountView} from "../../../general/utils.js"; +import {mountView} from "../../../general/utils"; import {TemplateView} from "../../../general/TemplateView.js"; import {Popup} from "../../../general/Popup.js"; import {Menu} from "../../../general/Menu.js"; diff --git a/src/platform/web/ui/session/room/timeline/ReactionsView.js b/src/platform/web/ui/session/room/timeline/ReactionsView.js index 12f3b428..2a349a76 100644 --- a/src/platform/web/ui/session/room/timeline/ReactionsView.js +++ b/src/platform/web/ui/session/room/timeline/ReactionsView.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ListView} from "../../../general/ListView.js"; +import {ListView} from "../../../general/ListView"; import {TemplateView} from "../../../general/TemplateView.js"; export class ReactionsView extends ListView {