Refactored table from MethodsForEpisodeView into a reusable Table widget.

This commit is contained in:
Daan Vanden Bosch 2019-09-07 17:49:21 +02:00
parent 1c2473c24f
commit a28a8ce624
10 changed files with 344 additions and 289 deletions

72
src/core/gui/Table.css Normal file
View File

@ -0,0 +1,72 @@
.core_Table {
display: block;
box-sizing: border-box;
overflow: auto;
background-color: var(--bg-color);
border-collapse: collapse;
}
.core_Table tr {
display: flex;
align-items: stretch;
}
.core_Table thead tr {
position: sticky;
top: 0;
z-index: 2;
}
.core_Table th,
.core_Table td {
box-sizing: border-box;
overflow: hidden;
text-overflow: ellipsis;
padding: 3px 6px;
border-right: solid 1px var(--border-color);
border-bottom: solid 1px var(--border-color);
background-color: var(--bg-color);
}
.core_Table tbody {
user-select: text;
cursor: text;
}
.core_Table tbody th,
.core_Table tbody td {
white-space: nowrap;
}
.core_Table tbody th {
text-align: left;
}
.core_Table th.input {
padding: 0;
overflow: visible;
}
.core_Table th.input .core_DurationInput {
z-index: 0;
height: 100%;
width: 100%;
border: none;
}
.core_Table th.input .core_DurationInput:hover,
.core_Table th.input .core_DurationInput:focus-within {
margin: -1px;
height: calc(100% + 2px);
width: calc(100% + 2px);
}
.core_Table th.input .core_DurationInput:hover {
z-index: 4;
border: var(--input-border-hover);
}
.core_Table th.input .core_DurationInput:focus-within {
z-index: 6;
border: var(--input-border-focus);
}

112
src/core/gui/Table.ts Normal file
View File

@ -0,0 +1,112 @@
import { Widget, WidgetOptions } from "./Widget";
import { el } from "./dom";
import {
ListChangeType,
ListProperty,
ListPropertyChangeEvent,
} from "../observable/property/list/ListProperty";
import { Disposer } from "../observable/Disposer";
import "./Table.css";
export type Column<T> = {
title: string;
sticky?: boolean;
width?: number;
create_cell(value: T, disposer: Disposer): HTMLTableCellElement;
};
export type TableOptions<T> = WidgetOptions & {
values: ListProperty<T>;
columns: Column<T>[];
};
export class Table<T> extends Widget<HTMLTableElement> {
private readonly table_disposer = this.disposable(new Disposer());
private readonly tbody_element = el.tbody();
private readonly values: ListProperty<T>;
private readonly columns: Column<T>[];
constructor(options: TableOptions<T>) {
super(el.table({ class: "core_Table" }), options);
this.values = options.values;
this.columns = options.columns;
const thead_element = el.thead();
const header_tr_element = el.tr();
let left = 0;
header_tr_element.append(
...this.columns.map(column => {
const th = el.th({
text: column.title,
});
if (column.width != undefined) th.style.width = `${column.width}px`;
if (column.sticky) {
th.style.position = "sticky";
th.style.left = `${left}px`;
left += column.width || 0;
}
return th;
}),
);
thead_element.append(header_tr_element);
this.tbody_element = el.tbody();
this.element.append(thead_element, this.tbody_element);
this.disposables(this.values.observe_list(this.update_table));
}
private update_table = (change: ListPropertyChangeEvent<T>): void => {
if (change.type === ListChangeType.ListChange) {
this.splice_rows(change.index, change.removed.length, change.inserted);
} 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("afterend", rows[i]);
}
}
};
private create_row = (index: number, value: T): HTMLTableRowElement => {
const disposer = this.table_disposer.add(new Disposer());
let left = 0;
return el.tr(
{},
...this.columns.map(column => {
const cell = column.create_cell(value, disposer);
if (column.width != undefined) cell.style.width = `${column.width}px`;
if (column.sticky) {
cell.style.position = "sticky";
cell.style.left = `${left}px`;
left += column.width || 0;
}
return cell;
}),
);
};
}

View File

@ -7,6 +7,7 @@ import { WidgetProperty } from "../observable/property/WidgetProperty";
import { Property } from "../observable/property/Property";
export type WidgetOptions = {
class?: string;
enabled?: boolean | Property<boolean>;
tooltip?: string | Property<string>;
};
@ -52,6 +53,10 @@ export abstract class Widget<E extends HTMLElement = HTMLElement> implements Dis
this.tooltip = this._tooltip;
if (options) {
if (options.class) {
this.element.classList.add(options.class);
}
if (typeof options.enabled === "boolean") {
this.enabled.val = options.enabled;
} else if (options.enabled) {

View File

@ -19,7 +19,11 @@ export class Disposer implements Disposable {
}
private _disposed = false;
private readonly disposables: Disposable[] = [];
private readonly disposables: Disposable[];
constructor(...disposables: Disposable[]) {
this.disposables = disposables;
}
/**
* Add a single disposable and return the given disposable.
@ -32,6 +36,17 @@ export class Disposer implements Disposable {
return disposable;
}
/**
* Insert a single disposable at the given index and return the given disposable.
*/
insert<T extends Disposable>(index: number, disposable: T): T {
if (!this._disposed) {
this.disposables.splice(index, 0, disposable);
}
return disposable;
}
/**
* Add 0 or more disposables.
*/
@ -47,13 +62,7 @@ export class Disposer implements Disposable {
* Disposes all held disposables.
*/
dispose_all(): void {
for (const disposable of this.disposables.splice(0, this.disposables.length)) {
try {
disposable.dispose();
} catch (e) {
logger.warn("Error while disposing.", e);
}
}
this.dispose_at(0, this.disposables.length);
}
/**
@ -63,4 +72,14 @@ export class Disposer implements Disposable {
this.dispose_all();
this._disposed = true;
}
dispose_at(index: number, amount: number = 1): void {
for (const disposable of this.disposables.splice(index, amount)) {
try {
disposable.dispose();
} catch (e) {
logger.warn("Error while disposing.", e);
}
}
}
}

View File

@ -2,45 +2,23 @@ import { Property } from "../Property";
import { Disposable } from "../../Disposable";
export enum ListChangeType {
Insertion,
Removal,
Replacement,
Update,
ListChange,
ValueChange,
}
export type ListPropertyChangeEvent<T> =
| ListInsertion<T>
| ListRemoval<T>
| ListReplacement<T>
| ListUpdate<T>;
export type ListPropertyChangeEvent<T> = ListChange<T> | ListValueChange<T>;
export type ListInsertion<T> = {
readonly type: ListChangeType.Insertion;
readonly inserted: T[];
readonly from: number;
readonly to: number;
};
export type ListRemoval<T> = {
readonly type: ListChangeType.Removal;
readonly removed: T[];
readonly from: number;
readonly to: number;
};
export type ListReplacement<T> = {
readonly type: ListChangeType.Replacement;
readonly removed: T[];
readonly inserted: T[];
readonly from: number;
readonly removed_to: number;
readonly inserted_to: number;
};
export type ListUpdate<T> = {
readonly type: ListChangeType.Update;
readonly updated: T[];
export type ListChange<T> = {
readonly type: ListChangeType.ListChange;
readonly index: number;
readonly removed: T[];
readonly inserted: T[];
};
export type ListValueChange<T> = {
readonly type: ListChangeType.ValueChange;
readonly index: number;
readonly updated: T[];
};
export interface ListProperty<T> extends Property<T[]> {

View File

@ -23,45 +23,43 @@ export class SimpleListProperty<T> extends AbstractProperty<T[]>
}
get_val(): T[] {
return this.elements;
return this.values;
}
set_val(elements: T[]): T[] {
const removed = this.elements.splice(0, this.elements.length, ...elements);
const removed = this.values.splice(0, this.values.length, ...elements);
this.finalize_update({
type: ListChangeType.Replacement,
type: ListChangeType.ListChange,
index: 0,
removed,
inserted: elements,
from: 0,
removed_to: removed.length,
inserted_to: elements.length,
});
return removed;
}
private readonly _length = property(0);
private readonly elements: T[];
private readonly values: T[];
private readonly extract_observables?: (element: T) => Observable<any>[];
/**
* Internal observers which observe observables related to this list's elements so that their
* Internal observers which observe observables related to this list's values so that their
* changes can be propagated via update events.
*/
private readonly element_observers: { index: number; disposables: Disposable[] }[] = [];
private readonly value_observers: { index: number; disposables: Disposable[] }[] = [];
/**
* External observers which are observing this list.
*/
private readonly list_observers: ((change: ListPropertyChangeEvent<T>) => void)[] = [];
/**
* @param extract_observables - Extractor function called on each element in this list. Changes
* @param extract_observables - Extractor function called on each value in this list. Changes
* to the returned observables will be propagated via update events.
* @param elements - Initial elements of this list.
* @param values - Initial values of this list.
*/
constructor(extract_observables?: (element: T) => Observable<any>[], ...elements: T[]) {
constructor(extract_observables?: (element: T) => Observable<any>[], ...values: T[]) {
super();
this.length = this._length;
this.elements = elements;
this.values = values;
this.extract_observables = extract_observables;
}
@ -69,8 +67,8 @@ export class SimpleListProperty<T> extends AbstractProperty<T[]>
observer: (change: ListPropertyChangeEvent<T>) => void,
options?: { call_now?: true },
): Disposable {
if (this.element_observers.length === 0 && this.extract_observables) {
this.replace_element_observers(this.elements, 0, Infinity);
if (this.value_observers.length === 0 && this.extract_observables) {
this.replace_element_observers(0, Infinity, this.values);
}
if (!this.list_observers.includes(observer)) {
@ -79,10 +77,10 @@ export class SimpleListProperty<T> extends AbstractProperty<T[]>
if (options && options.call_now) {
this.call_list_observer(observer, {
type: ListChangeType.Insertion,
inserted: this.elements,
from: 0,
to: this.elements.length,
type: ListChangeType.ListChange,
index: 0,
removed: [],
inserted: this.values,
});
}
@ -95,13 +93,13 @@ export class SimpleListProperty<T> extends AbstractProperty<T[]>
}
if (this.list_observers.length === 0) {
for (const { disposables } of this.element_observers) {
for (const { disposables } of this.value_observers) {
for (const disposable of disposables) {
disposable.dispose();
}
}
this.element_observers.splice(0, Infinity);
this.value_observers.splice(0, Infinity);
}
},
};
@ -116,35 +114,31 @@ export class SimpleListProperty<T> extends AbstractProperty<T[]>
}
update(f: (element: T[]) => T[]): void {
this.splice(0, this.elements.length, ...f(this.elements));
this.splice(0, this.values.length, ...f(this.values));
}
get(index: number): T {
return this.elements[index];
return this.values[index];
}
set(index: number, element: T): void {
const removed = [this.elements[index]];
this.elements[index] = element;
const removed = [this.values[index]];
this.values[index] = element;
this.finalize_update({
type: ListChangeType.Replacement,
type: ListChangeType.ListChange,
index,
removed,
inserted: [element],
from: index,
removed_to: index + 1,
inserted_to: index + 1,
});
}
clear(): void {
const removed = this.elements.splice(0, this.elements.length);
const removed = this.values.splice(0, this.values.length);
this.finalize_update({
type: ListChangeType.Replacement,
type: ListChangeType.ListChange,
index: 0,
removed,
inserted: [],
from: 0,
removed_to: removed.length,
inserted_to: 0,
});
}
@ -152,18 +146,16 @@ export class SimpleListProperty<T> extends AbstractProperty<T[]>
let removed: T[];
if (delete_count == undefined) {
removed = this.elements.splice(index);
removed = this.values.splice(index);
} else {
removed = this.elements.splice(index, delete_count, ...items);
removed = this.values.splice(index, delete_count, ...items);
}
this.finalize_update({
type: ListChangeType.Replacement,
type: ListChangeType.ListChange,
index,
removed,
inserted: items,
from: index,
removed_to: index + removed.length,
inserted_to: index + items.length,
});
return removed;
@ -171,39 +163,27 @@ export class SimpleListProperty<T> extends AbstractProperty<T[]>
/**
* Does the following in the given order:
* - Updates element observers
* - Updates value observers
* - Emits ListPropertyChangeEvent
* - Emits PropertyChangeEvent
* - Sets length
*/
protected finalize_update(change: ListPropertyChangeEvent<T>): void {
if (this.list_observers.length && this.extract_observables) {
switch (change.type) {
case ListChangeType.Insertion:
this.replace_element_observers(change.inserted, change.from, 0);
break;
case ListChangeType.Removal:
this.replace_element_observers([], change.from, change.removed.length);
break;
case ListChangeType.Replacement:
this.replace_element_observers(
change.inserted,
change.from,
change.removed.length,
);
break;
}
if (
this.list_observers.length &&
this.extract_observables &&
change.type === ListChangeType.ListChange
) {
this.replace_element_observers(change.index, change.removed.length, change.inserted);
}
for (const observer of this.list_observers) {
this.call_list_observer(observer, change);
}
this.emit(this.elements);
this.emit(this.values);
this._length.val = this.elements.length;
this._length.val = this.values.length;
}
private call_list_observer(
@ -217,10 +197,10 @@ export class SimpleListProperty<T> extends AbstractProperty<T[]>
}
}
private replace_element_observers(new_elements: T[], from: number, amount: number): void {
private replace_element_observers(from: number, amount: number, new_elements: T[]): void {
let index = from;
const removed = this.element_observers.splice(
const removed = this.value_observers.splice(
from,
amount,
...new_elements.map(element => {
@ -229,7 +209,7 @@ export class SimpleListProperty<T> extends AbstractProperty<T[]>
disposables: this.extract_observables!(element).map(observable =>
observable.observe(() => {
this.finalize_update({
type: ListChangeType.Update,
type: ListChangeType.ValueChange,
updated: [element],
index: obj.index,
});
@ -247,8 +227,8 @@ export class SimpleListProperty<T> extends AbstractProperty<T[]>
}
}
while (index < this.element_observers.length) {
this.element_observers[index].index += index;
while (index < this.value_observers.length) {
this.value_observers[index].index += index;
}
}
}

View File

@ -1,91 +1,4 @@
.hunt_optimizer_MethodsForEpisodeView table {
display: block;
box-sizing: border-box;
overflow: auto;
.hunt_optimizer_MethodsForEpisodeView_table {
width: 100%;
height: 100%;
background-color: var(--bg-color);
border-collapse: collapse;
}
.hunt_optimizer_MethodsForEpisodeView tr {
display: flex;
align-items: stretch;
}
.hunt_optimizer_MethodsForEpisodeView thead tr {
position: sticky;
top: 0;
z-index: 2;
}
.hunt_optimizer_MethodsForEpisodeView th,
.hunt_optimizer_MethodsForEpisodeView td {
box-sizing: border-box;
overflow: hidden;
text-overflow: ellipsis;
width: 80px;
padding: 3px 6px;
border-right: solid 1px var(--border-color);
border-bottom: solid 1px var(--border-color);
background-color: var(--bg-color);
}
.hunt_optimizer_MethodsForEpisodeView th:first-child {
position: sticky;
left: 0;
width: 250px;
}
.hunt_optimizer_MethodsForEpisodeView th:nth-child(2) {
position: sticky;
left: 250px;
width: 60px;
}
.hunt_optimizer_MethodsForEpisodeView tbody {
user-select: text;
cursor: text;
}
.hunt_optimizer_MethodsForEpisodeView tbody th,
.hunt_optimizer_MethodsForEpisodeView tbody td {
white-space: nowrap;
}
.hunt_optimizer_MethodsForEpisodeView tbody th {
text-align: left;
}
.hunt_optimizer_MethodsForEpisodeView tbody td {
text-align: right;
}
.hunt_optimizer_MethodsForEpisodeView th.input {
padding: 0;
overflow: visible;
}
.hunt_optimizer_MethodsForEpisodeView th.input .core_DurationInput {
z-index: 0;
height: 100%;
width: 100%;
border: none;
}
.hunt_optimizer_MethodsForEpisodeView th.input .core_DurationInput:hover,
.hunt_optimizer_MethodsForEpisodeView th.input .core_DurationInput:focus-within {
margin: -1px;
height: calc(100% + 2px);
width: calc(100% + 2px);
}
.hunt_optimizer_MethodsForEpisodeView th.input .core_DurationInput:hover {
z-index: 4;
border: var(--input-border-hover);
}
.hunt_optimizer_MethodsForEpisodeView th.input .core_DurationInput:focus-within {
z-index: 6;
border: var(--input-border-focus);
}

View File

@ -12,12 +12,12 @@ import "./MethodsForEpisodeView.css";
import { Disposer } from "../../core/observable/Disposer";
import { DurationInput } from "../../core/gui/DurationInput";
import { Disposable } from "../../core/observable/Disposable";
import { Table } from "../../core/gui/Table";
import { list_property } from "../../core/observable";
export class MethodsForEpisodeView extends ResizableWidget {
private readonly episode: Episode;
private readonly enemy_types: NpcType[];
private readonly tbody_element: HTMLTableSectionElement;
private readonly time_disposer = this.disposable(new Disposer());
private hunt_methods_observer?: Disposable;
constructor(episode: Episode) {
@ -27,25 +27,55 @@ export class MethodsForEpisodeView extends ResizableWidget {
this.enemy_types = ENEMY_NPC_TYPES.filter(type => npc_data(type).episode === this.episode);
const table_element = el.table();
const thead_element = el.thead();
const header_tr_element = el.tr();
const hunt_methods = list_property<HuntMethodModel>();
header_tr_element.append(el.th({ text: "Method" }), el.th({ text: "Time" }));
const table = this.disposable(
new Table({
class: "hunt_optimizer_MethodsForEpisodeView_table",
values: hunt_methods,
columns: [
{
title: "Method",
sticky: true,
width: 250,
create_cell(method: HuntMethodModel): HTMLTableDataCellElement {
return el.th({ text: method.name });
},
},
{
title: "Time",
sticky: true,
width: 60,
create_cell(
method: HuntMethodModel,
disposer: Disposer,
): HTMLTableDataCellElement {
const time_input = disposer.add(new DurationInput(method.time.val));
for (const enemy_type of this.enemy_types) {
header_tr_element.append(
el.th({
text: npc_data(enemy_type).simple_name,
}),
);
}
disposer.add(
time_input.value.observe(({ value }) =>
method.set_user_time(value),
),
);
this.tbody_element = el.tbody();
return el.th({ class: "input" }, time_input.element);
},
},
...this.enemy_types.map(enemy_type => {
return {
title: npc_data(enemy_type).simple_name,
width: 80,
create_cell(method: HuntMethodModel): HTMLTableDataCellElement {
const count = method.enemy_counts.get(enemy_type);
return el.td({ text: count == undefined ? "" : count.toString() });
},
};
}),
],
}),
);
thead_element.append(header_tr_element);
table_element.append(thead_element, this.tbody_element);
this.element.append(table_element);
this.element.append(table.element);
this.disposable(
hunt_method_stores.observe_current(
@ -55,7 +85,11 @@ export class MethodsForEpisodeView extends ResizableWidget {
}
this.hunt_methods_observer = hunt_method_store.methods.observe(
this.update_table,
({ value }) => {
hunt_methods.val = value.filter(
method => method.episode === this.episode,
);
},
{
call_now: true,
},
@ -73,35 +107,4 @@ export class MethodsForEpisodeView extends ResizableWidget {
this.hunt_methods_observer.dispose();
}
}
private update_table = ({ value: methods }: { value: HuntMethodModel[] }) => {
this.time_disposer.dispose_all();
const frag = document.createDocumentFragment();
for (const method of methods) {
if (method.episode === this.episode) {
const time_input = this.time_disposer.add(new DurationInput(method.time.val));
this.time_disposer.add(
time_input.value.observe(({ value }) => method.set_user_time(value)),
);
const cells: HTMLTableCellElement[] = [
el.th({ text: method.name }),
el.th({ class: "input" }, time_input.element),
];
// One cell per enemy type.
for (const enemy_type of this.enemy_types) {
const count = method.enemy_counts.get(enemy_type);
cells.push(el.td({ text: count == undefined ? "" : count.toString() }));
}
frag.append(el.tr({}, ...cells));
}
}
this.tbody_element.innerHTML = "";
this.tbody_element.append(frag);
};
}

View File

@ -58,50 +58,23 @@ export class WantedItemsView extends Widget {
}
private update_table = (change: ListPropertyChangeEvent<WantedItemModel>): void => {
switch (change.type) {
case ListChangeType.Insertion:
{
const rows = change.inserted.map(this.create_row);
if (change.type === ListChangeType.ListChange) {
for (let i = 0; i < change.removed.length; i++) {
this.tbody_element.children[change.index].remove();
}
if (change.from >= this.tbody_element.childElementCount) {
this.tbody_element.append(...rows);
} else {
for (let i = change.from; i < change.to; i++) {
this.tbody_element.children[i].insertAdjacentElement(
"afterend",
rows[i - change.from],
);
}
}
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(
"afterend",
rows[i],
);
}
break;
case ListChangeType.Removal:
for (let i = change.from; i < change.to; i++) {
this.tbody_element.children[change.from].remove();
}
break;
case ListChangeType.Replacement:
{
const rows = change.inserted.map(this.create_row);
for (let i = change.from; i < change.removed_to; i++) {
this.tbody_element.children[change.from].remove();
}
if (change.from >= this.tbody_element.childElementCount) {
this.tbody_element.append(...rows);
} else {
for (let i = change.from; i < change.inserted_to; i++) {
this.tbody_element.children[i].insertAdjacentElement(
"afterend",
rows[i - change.from],
);
}
}
}
break;
}
}
};

View File

@ -24,7 +24,7 @@ export class HuntMethodStore implements Disposable {
private readonly disposer = new Disposer();
constructor(server: Server, methods: HuntMethodModel[]) {
this.methods = list_property(undefined, ...methods);
this.methods = list_property(method => [method.user_time], ...methods);
this.disposer.add(
this.methods.observe_list(() =>