From 8622b07bdeb84519e445d1e4caac70ea0cba450c Mon Sep 17 00:00:00 2001 From: Daan Vanden Bosch Date: Tue, 17 Sep 2019 17:26:25 +0200 Subject: [PATCH] Added bind_children_to function to efficiently update child nodes of an HTML element based on the contents of a ListProperty. --- src/core/gui/Table.ts | 109 ++++++++-------------- src/core/gui/dom.ts | 59 ++++++++++++ src/hunt_optimizer/gui/WantedItemsView.ts | 56 ++++------- 3 files changed, 118 insertions(+), 106 deletions(-) diff --git a/src/core/gui/Table.ts b/src/core/gui/Table.ts index 2c229d7f..fded2ca6 100644 --- a/src/core/gui/Table.ts +++ b/src/core/gui/Table.ts @@ -1,13 +1,10 @@ import { Widget, WidgetOptions } from "./Widget"; -import { el } from "./dom"; -import { - ListChangeType, - ListProperty, - ListPropertyChangeEvent, -} from "../observable/property/list/ListProperty"; +import { bind_children_to, el } from "./dom"; +import { ListProperty } from "../observable/property/list/ListProperty"; import { Disposer } from "../observable/Disposer"; import "./Table.css"; import Logger = require("js-logger"); +import { Disposable } from "../observable/Disposable"; const logger = Logger.get("core/gui/Table"); @@ -41,7 +38,6 @@ export type TableOptions = WidgetOptions & { export class Table extends Widget { readonly element = el.table({ class: "core_Table" }); - private readonly table_disposer = this.disposable(new Disposer()); private readonly tbody_element = el.tbody(); private readonly footer_row_element?: HTMLTableRowElement; private readonly values: ListProperty; @@ -138,77 +134,54 @@ export class Table extends Widget { this.create_footer(); } - this.disposables(this.values.observe_list(this.update_table)); - - this.splice_rows(0, this.values.length.val, this.values.val); + this.disposables( + bind_children_to(this.tbody_element, this.values, this.create_row), + this.values.observe(this.update_footer), + ); this.finalize_construction(Table.prototype); } - private update_table = (change: ListPropertyChangeEvent): void => { - if (change.type === ListChangeType.ListChange) { - this.splice_rows(change.index, change.removed.length, change.inserted); - this.update_footer(); - } else if (change.type === ListChangeType.ValueChange) { - // TODO: update rows - } - }; - - private splice_rows = (index: number, amount: number, inserted: T[]) => { - for (let i = 0; i < amount; i++) { - this.tbody_element.children[index].remove(); - } - - this.table_disposer.dispose_at(index, amount); - - const rows = inserted.map((value, i) => this.create_row(index + i, value)); - - if (index >= this.tbody_element.childElementCount) { - this.tbody_element.append(...rows); - } else { - for (let i = 0; i < amount; i++) { - this.tbody_element.children[index + i].insertAdjacentElement( - "beforebegin", - rows[i], - ); - } - } - }; - - private create_row = (index: number, value: T): HTMLTableRowElement => { - const disposer = this.table_disposer.add(new Disposer()); + private create_row = (value: T, index: number): [HTMLTableRowElement, Disposable] => { + const disposer = new Disposer(); let left = 0; - return el.tr( - {}, - ...this.columns.map((column, i) => { - const cell = column.fixed ? el.th() : el.td(); + return [ + el.tr( + {}, + ...this.columns.map((column, i) => { + const cell = column.fixed ? el.th() : el.td(); - try { - const content = column.render_cell(value, disposer); + try { + const content = column.render_cell(value, disposer); - cell.append(content); + cell.append(content); - if (column.input) cell.classList.add("input"); + if (column.input) cell.classList.add("input"); - if (column.fixed) { - cell.classList.add("fixed"); - cell.style.left = `${left}px`; - left += column.width || 0; + if (column.fixed) { + cell.classList.add("fixed"); + cell.style.left = `${left}px`; + left += column.width || 0; + } + + cell.style.width = `${column.width}px`; + + if (column.text_align) cell.style.textAlign = column.text_align; + + if (column.tooltip) cell.title = column.tooltip(value); + } catch (e) { + logger.warn( + `Error while rendering cell for index ${index}, column ${i}.`, + e, + ); } - cell.style.width = `${column.width}px`; - - if (column.text_align) cell.style.textAlign = column.text_align; - - if (column.tooltip) cell.title = column.tooltip(value); - } catch (e) { - logger.warn(`Error while rendering cell for index ${index}, column ${i}.`, e); - } - - return cell; - }), - ); + return cell; + }), + ), + disposer, + ]; }; private create_footer(): void { @@ -240,7 +213,7 @@ export class Table extends Widget { this.footer_row_element!.append(...footer_cells); } - private update_footer(): void { + private update_footer = (): void => { if (!this.footer_row_element) return; const col_count = this.columns.length; @@ -254,5 +227,5 @@ export class Table extends Widget { cell.title = column.footer.tooltip ? column.footer.tooltip() : ""; } } - } + }; } diff --git a/src/core/gui/dom.ts b/src/core/gui/dom.ts index 1a4b7aa2..b7cc32b3 100644 --- a/src/core/gui/dom.ts +++ b/src/core/gui/dom.ts @@ -2,6 +2,12 @@ import { Disposable } from "../observable/Disposable"; import { Observable } from "../observable/Observable"; import { is_property } from "../observable/property/Property"; import { SectionId } from "../model"; +import { + ListChangeType, + ListProperty, + ListPropertyChangeEvent, +} from "../observable/property/list/ListProperty"; +import { Disposer } from "../observable/Disposer"; type ElementAttributes = { class?: string; @@ -188,3 +194,56 @@ export function disposable_listener( }, }; } + +export function bind_children_to( + element: HTMLElement, + list: ListProperty, + create_child: (value: T, index: number) => HTMLElement | [HTMLElement, Disposable], +): Disposable { + const children_disposer = new Disposer(); + + const observer = list.observe_list((change: ListPropertyChangeEvent) => { + if (change.type === ListChangeType.ListChange) { + splice_children(change.index, change.removed.length, change.inserted); + } else if (change.type === ListChangeType.ValueChange) { + // TODO: update children + } + }); + + function splice_children(index: number, removed_count: number, inserted: T[]): void { + for (let i = 0; i < removed_count; i++) { + element.children[index].remove(); + } + + children_disposer.dispose_at(index, removed_count); + + const children = inserted.map((value, i) => { + const child = create_child(value, index + i); + + if (Array.isArray(child)) { + children_disposer.insert(index + i, child[1]); + return child[0]; + } else { + return child; + } + }); + + if (index >= element.childElementCount) { + element.append(...children); + } else { + for (let i = 0; i < removed_count; i++) { + element.children[index + i].insertAdjacentElement("beforebegin", children[i]); + } + } + } + + splice_children(0, 0, list.val); + + return { + dispose(): void { + observer.dispose(); + children_disposer.dispose(); + element.innerHTML = ""; + }, + }; +} diff --git a/src/hunt_optimizer/gui/WantedItemsView.ts b/src/hunt_optimizer/gui/WantedItemsView.ts index ee3614b0..a4327713 100644 --- a/src/hunt_optimizer/gui/WantedItemsView.ts +++ b/src/hunt_optimizer/gui/WantedItemsView.ts @@ -1,24 +1,20 @@ -import { el, Icon } from "../../core/gui/dom"; +import { bind_children_to, el, Icon } from "../../core/gui/dom"; import "./WantedItemsView.css"; import { Button } from "../../core/gui/Button"; import { Disposer } from "../../core/observable/Disposer"; import { Widget } from "../../core/gui/Widget"; -import { - ListChangeType, - ListPropertyChangeEvent, -} from "../../core/observable/property/list/ListProperty"; import { WantedItemModel } from "../model"; import { NumberInput } from "../../core/gui/NumberInput"; import { hunt_optimizer_stores } from "../stores/HuntOptimizerStore"; import { ComboBox } from "../../core/gui/ComboBox"; import { list_property } from "../../core/observable"; import { ItemType } from "../../core/model/items"; +import { Disposable } from "../../core/observable/Disposable"; export class WantedItemsView extends Widget { readonly element = el.div({ class: "hunt_optimizer_WantedItemsView" }); private readonly tbody_element = el.tbody(); - private readonly table_disposer = this.disposable(new Disposer()); private readonly store_disposer = this.disposable(new Disposer()); constructor() { @@ -56,7 +52,11 @@ export class WantedItemsView extends Widget { this.store_disposer.dispose_all(); this.store_disposer.add_all( - hunt_optimizer_store.wanted_items.observe_list(this.update_table), + bind_children_to( + this.tbody_element, + hunt_optimizer_store.wanted_items, + this.create_row, + ), combo_box.selected.observe(({ value: item_type }) => { if (item_type) { @@ -78,31 +78,8 @@ export class WantedItemsView extends Widget { this.finalize_construction(WantedItemsView.prototype); } - private update_table = (change: ListPropertyChangeEvent): void => { - if (change.type === ListChangeType.ListChange) { - for (let i = 0; i < change.removed.length; i++) { - this.tbody_element.children[change.index].remove(); - } - - this.table_disposer.dispose_at(change.index, change.removed.length); - - const rows = change.inserted.map(this.create_row); - - if (change.index >= this.tbody_element.childElementCount) { - this.tbody_element.append(...rows); - } else { - for (let i = 0; i < change.inserted.length; i++) { - this.tbody_element.children[change.index + i].insertAdjacentElement( - "beforebegin", - rows[i], - ); - } - } - } - }; - - private create_row = (wanted_item: WantedItemModel): HTMLTableRowElement => { - const row_disposer = this.table_disposer.add(new Disposer()); + private create_row = (wanted_item: WantedItemModel): [HTMLTableRowElement, Disposable] => { + const row_disposer = new Disposer(); const amount_input = row_disposer.add( new NumberInput(wanted_item.amount.val, { min: 0, step: 1 }), @@ -121,11 +98,14 @@ export class WantedItemsView extends Widget { ), ); - return el.tr( - {}, - el.td({}, amount_input.element), - el.td({ text: wanted_item.item_type.name }), - el.td({}, remove_button.element), - ); + return [ + el.tr( + {}, + el.td({}, amount_input.element), + el.td({ text: wanted_item.item_type.name }), + el.td({}, remove_button.element), + ), + row_disposer, + ]; }; }