Improved observables and ported more of the quest editor to the new GUI system.

This commit is contained in:
Daan Vanden Bosch 2019-08-22 22:45:01 +02:00
parent 18a8ac1ad6
commit 8e13441f26
57 changed files with 1292 additions and 280 deletions

View File

@ -1,10 +1,10 @@
import { NavigationView } from "./NavigationView"; import { NavigationView } from "./NavigationView";
import { MainContentView } from "./MainContentView"; import { MainContentView } from "./MainContentView";
import { create_el } from "../../core/gui/dom"; import { el } from "../../core/gui/dom";
import { ResizableView } from "../../core/gui/ResizableView"; import { ResizableView } from "../../core/gui/ResizableView";
export class ApplicationView extends ResizableView { export class ApplicationView extends ResizableView {
element = create_el("div", "application_ApplicationView"); element = el("div", { class: "application_ApplicationView" });
private menu_view = this.disposable(new NavigationView()); private menu_view = this.disposable(new NavigationView());
private main_content_view = this.disposable(new MainContentView()); private main_content_view = this.disposable(new MainContentView());
@ -12,6 +12,8 @@ export class ApplicationView extends ResizableView {
constructor() { constructor() {
super(); super();
this.element.id = "root";
this.element.append(this.menu_view.element, this.main_content_view.element); this.element.append(this.menu_view.element, this.main_content_view.element);
} }

View File

@ -1,4 +1,4 @@
import { create_el } from "../../core/gui/dom"; import { el } from "../../core/gui/dom";
import { gui_store, GuiTool } from "../../core/stores/GuiStore"; import { gui_store, GuiTool } from "../../core/stores/GuiStore";
import { LazyView } from "../../core/gui/LazyView"; import { LazyView } from "../../core/gui/LazyView";
import { ResizableView } from "../../core/gui/ResizableView"; import { ResizableView } from "../../core/gui/ResizableView";
@ -12,7 +12,7 @@ const TOOLS: [GuiTool, () => Promise<ResizableView>][] = [
]; ];
export class MainContentView extends ResizableView { export class MainContentView extends ResizableView {
element = create_el("div", "application_MainContentView"); element = el("div", { class: "application_MainContentView" });
private tool_views = new Map( private tool_views = new Map(
TOOLS.map(([tool, create_view]) => [tool, this.disposable(new LazyView(create_view))]), TOOLS.map(([tool, create_view]) => [tool, this.disposable(new LazyView(create_view))]),
@ -25,7 +25,7 @@ export class MainContentView extends ResizableView {
this.element.append(tool_view.element); this.element.append(tool_view.element);
} }
const tool_view = this.tool_views.get(gui_store.tool.get()); const tool_view = this.tool_views.get(gui_store.tool.val);
if (tool_view) tool_view.visible = true; if (tool_view) tool_view.visible = true;
this.disposable(gui_store.tool.observe(this.tool_changed)); this.disposable(gui_store.tool.observe(this.tool_changed));
@ -41,9 +41,10 @@ export class MainContentView extends ResizableView {
return this; return this;
} }
private tool_changed = (new_tool: GuiTool, { old_value }: { old_value: GuiTool }) => { private tool_changed = (new_tool: GuiTool) => {
const old_view = this.tool_views.get(old_value); for (const tool of this.tool_views.values()) {
if (old_view) old_view.visible = false; tool.visible = false;
}
const new_view = this.tool_views.get(new_tool); const new_view = this.tool_views.get(new_tool);
if (new_view) new_view.visible = true; if (new_view) new_view.visible = true;

View File

@ -13,11 +13,12 @@
.application_ToolButton label { .application_ToolButton label {
box-sizing: border-box; box-sizing: border-box;
display: inline-block; display: inline-flex;
flex-direction: row;
align-items: center;
font-size: 15px; font-size: 15px;
height: 100%; height: 100%;
padding: 0 20px; padding: 0 20px;
line-height: 29px;
color: hsl(0, 0%, 65%); color: hsl(0, 0%, 65%);
} }

View File

@ -1,4 +1,4 @@
import { create_el } from "../../core/gui/dom"; import { el } from "../../core/gui/dom";
import "./NavigationView.css"; import "./NavigationView.css";
import { gui_store, GuiTool } from "../../core/stores/GuiStore"; import { gui_store, GuiTool } from "../../core/stores/GuiStore";
import { View } from "../../core/gui/View"; import { View } from "../../core/gui/View";
@ -10,7 +10,7 @@ const TOOLS: [GuiTool, string][] = [
]; ];
export class NavigationView extends View { export class NavigationView extends View {
readonly element = create_el("div", "application_NavigationView"); readonly element = el("div", { class: "application_NavigationView" });
readonly height = 30; readonly height = 30;
@ -28,13 +28,13 @@ export class NavigationView extends View {
this.element.append(button.element); this.element.append(button.element);
} }
this.tool_changed(gui_store.tool.get()); this.tool_changed(gui_store.tool.val);
this.disposable(gui_store.tool.observe(this.tool_changed)); this.disposable(gui_store.tool.observe(this.tool_changed));
} }
private click(e: MouseEvent): void { private click(e: MouseEvent): void {
if (e.target instanceof HTMLLabelElement && e.target.control instanceof HTMLInputElement) { if (e.target instanceof HTMLLabelElement && e.target.control instanceof HTMLInputElement) {
gui_store.tool.set((GuiTool as any)[e.target.control.value]); gui_store.tool.val = (GuiTool as any)[e.target.control.value];
} }
} }
@ -45,10 +45,10 @@ export class NavigationView extends View {
} }
class ToolButton extends View { class ToolButton extends View {
element: HTMLElement = create_el("span"); element: HTMLElement = el("span");
private input: HTMLInputElement = create_el("input"); private input: HTMLInputElement = el("input");
private label: HTMLLabelElement = create_el("label"); private label: HTMLLabelElement = el("label");
constructor(tool: GuiTool, text: string) { constructor(tool: GuiTool, text: string) {
super(); super();

View File

@ -1,5 +1,7 @@
.core_Button { .core_Button {
display: inline-block; display: inline-flex;
flex-direction: row;
align-items: stretch;
box-sizing: border-box; box-sizing: border-box;
padding: 0; padding: 0;
border: solid 1px hsl(0, 0%, 10%); border: solid 1px hsl(0, 0%, 10%);
@ -8,11 +10,12 @@
} }
.core_Button .core_Button_inner { .core_Button .core_Button_inner {
display: inline-block; display: flex;
flex-direction: row;
align-items: center;
box-sizing: border-box; box-sizing: border-box;
background-color: hsl(0, 0%, 20%); background-color: hsl(0, 0%, 20%);
height: 24px; height: 24px;
line-height: 17px;
padding: 3px 8px; padding: 3px 8px;
border: solid 1px hsl(0, 0%, 35%); border: solid 1px hsl(0, 0%, 35%);
} }
@ -28,3 +31,9 @@
border-color: hsl(0, 0%, 30%); border-color: hsl(0, 0%, 30%);
color: hsl(0, 0%, 75%); color: hsl(0, 0%, 75%);
} }
.core_Button:disabled .core_Button_inner {
background-color: hsl(0, 0%, 15%);
border-color: hsl(0, 0%, 25%);
color: hsl(0, 0%, 55%);
}

View File

@ -1,11 +1,11 @@
import { create_el } from "./dom"; import { el } from "./dom";
import { View } from "./View";
import "./Button.css"; import "./Button.css";
import { Observable } from "../observable/Observable"; import { Observable } from "../observable/Observable";
import { emitter } from "../observable"; import { emitter } from "../observable";
import { Control } from "./Control";
export class Button extends View { export class Button extends Control {
readonly element: HTMLButtonElement = create_el("button", "core_Button"); readonly element: HTMLButtonElement = el("button", { class: "core_Button" });
private readonly _click = emitter<MouseEvent>(); private readonly _click = emitter<MouseEvent>();
readonly click: Observable<MouseEvent> = this._click; readonly click: Observable<MouseEvent> = this._click;
@ -13,11 +13,10 @@ export class Button extends View {
constructor(text: string) { constructor(text: string) {
super(); super();
const inner_element = create_el("span", "core_Button_inner"); this.element.append(el("span", { class: "core_Button_inner", text }));
inner_element.textContent = text;
this.element.append(inner_element); this.enabled.observe(enabled => (this.element.disabled = !enabled));
this.element.onclick = (e: MouseEvent) => this._click.emit(e, undefined); this.element.onclick = (e: MouseEvent) => this._click.emit(e);
} }
} }

View File

@ -1,10 +1,10 @@
import { create_el } from "./dom"; import { el } from "./dom";
import { WritableProperty } from "../observable/WritableProperty"; import { WritableProperty } from "../observable/WritableProperty";
import { property } from "../observable"; import { property } from "../observable";
import { LabelledControl } from "./LabelledControl"; import { LabelledControl } from "./LabelledControl";
export class CheckBox extends LabelledControl { export class CheckBox extends LabelledControl {
readonly element: HTMLInputElement = create_el("input", "core_CheckBox"); readonly element: HTMLInputElement = el("input", { class: "core_CheckBox" });
readonly checked: WritableProperty<boolean> = property(false); readonly checked: WritableProperty<boolean> = property(false);
@ -14,7 +14,7 @@ export class CheckBox extends LabelledControl {
super(label); super(label);
this.element.type = "checkbox"; this.element.type = "checkbox";
this.element.onchange = () => this.checked.set(this.element.checked); this.element.onchange = () => (this.checked.val = this.element.checked);
this.disposables( this.disposables(
this.checked.observe(checked => (this.element.checked = checked)), this.checked.observe(checked => (this.element.checked = checked)),
@ -22,6 +22,6 @@ export class CheckBox extends LabelledControl {
this.enabled.observe(enabled => (this.element.disabled = !enabled)), this.enabled.observe(enabled => (this.element.disabled = !enabled)),
); );
this.checked.set(checked); this.checked.val = checked;
} }
} }

View File

@ -1,17 +1,21 @@
import { create_el } from "./dom"; import { el } from "./dom";
import { View } from "./View";
import "./FileButton.css"; import "./FileButton.css";
import "./Button.css"; import "./Button.css";
import { property } from "../observable"; import { property } from "../observable";
import { Property } from "../observable/Property"; import { Property } from "../observable/Property";
import { Control } from "./Control";
export class FileButton extends View { export class FileButton extends Control {
readonly element: HTMLLabelElement = create_el("label", "core_FileButton core_Button"); readonly element: HTMLLabelElement = el("label", {
class: "core_FileButton core_Button",
});
private readonly _files = property<File[]>([]); private readonly _files = property<File[]>([]);
readonly files: Property<File[]> = this._files; readonly files: Property<File[]> = this._files;
private input: HTMLInputElement = create_el("input", "core_FileButton_input"); private input: HTMLInputElement = el("input", {
class: "core_FileButton_input core_Button_inner",
});
constructor(text: string, accept: string = "") { constructor(text: string, accept: string = "") {
super(); super();
@ -20,15 +24,28 @@ export class FileButton extends View {
this.input.accept = accept; this.input.accept = accept;
this.input.onchange = () => { this.input.onchange = () => {
if (this.input.files && this.input.files.length) { if (this.input.files && this.input.files.length) {
this._files.set([...this.input.files!]); this._files.val = [...this.input.files!];
} else { } else {
this._files.set([]); this._files.val = [];
} }
}; };
const inner_element = create_el("span", "core_FileButton_inner core_Button_inner"); this.element.append(
inner_element.textContent = text; el("span", {
class: "core_FileButton_inner core_Button_inner",
text,
}),
this.input,
);
this.element.append(inner_element, this.input); this.enabled.observe(enabled => {
this.input.disabled = !enabled;
if (enabled) {
this.element.classList.remove("disabled");
} else {
this.element.classList.add("disabled");
}
});
} }
} }

View File

@ -1,12 +1,12 @@
import { View } from "./View"; import { View } from "./View";
import { create_el } from "./dom"; import { el } from "./dom";
import { WritableProperty } from "../observable/WritableProperty"; import { WritableProperty } from "../observable/WritableProperty";
import "./Label.css"; import "./Label.css";
import { property } from "../observable"; import { property } from "../observable";
import { Property } from "../observable/Property"; import { Property } from "../observable/Property";
export class Label extends View { export class Label extends View {
readonly element = create_el<HTMLLabelElement>("label", "core_Label"); readonly element = el<HTMLLabelElement>("label", { class: "core_Label" });
set for(id: string) { set for(id: string) {
this.element.htmlFor = id; this.element.htmlFor = id;
@ -20,7 +20,7 @@ export class Label extends View {
if (typeof text === "string") { if (typeof text === "string") {
this.element.append(text); this.element.append(text);
} else { } else {
this.element.append(text.get()); this.element.append(text.val);
this.disposable(text.observe(text => (this.element.textContent = text))); this.disposable(text.observe(text => (this.element.textContent = text)));
} }

View File

@ -1,10 +1,10 @@
import { View } from "./View"; import { View } from "./View";
import { create_el } from "./dom"; import { el } from "./dom";
import { Resizable } from "./Resizable"; import { Resizable } from "./Resizable";
import { ResizableView } from "./ResizableView"; import { ResizableView } from "./ResizableView";
export class LazyView extends ResizableView { export class LazyView extends ResizableView {
readonly element = create_el("div", "core_LazyView"); readonly element = el("div", { class: "core_LazyView" });
private _visible = false; private _visible = false;

View File

@ -1,22 +1,21 @@
import "./NumberInput.css"; import "./NumberInput.css";
import "./Input.css"; import "./Input.css";
import { create_el } from "./dom"; import { el } from "./dom";
import { WritableProperty } from "../observable/WritableProperty"; import { WritableProperty } from "../observable/WritableProperty";
import { property } from "../observable"; import { property } from "../observable";
import { LabelledControl } from "./LabelledControl"; import { LabelledControl } from "./LabelledControl";
import { is_any_property, Property } from "../observable/Property"; import { is_any_property, Property } from "../observable/Property";
export class NumberInput extends LabelledControl { export class NumberInput extends LabelledControl {
readonly element = create_el("span", "core_NumberInput core_Input"); readonly element = el("span", { class: "core_NumberInput core_Input" });
readonly value: WritableProperty<number> = property(0); readonly value: WritableProperty<number> = property(0);
readonly preferred_label_position = "left"; readonly preferred_label_position = "left";
private readonly input: HTMLInputElement = create_el( private readonly input: HTMLInputElement = el("input", {
"input", class: "core_NumberInput_inner core_Input_inner",
"core_NumberInput_inner core_Input_inner", });
);
constructor( constructor(
value = 0, value = 0,
@ -34,7 +33,7 @@ export class NumberInput extends LabelledControl {
this.set_prop("max", max); this.set_prop("max", max);
this.set_prop("step", step); this.set_prop("step", step);
this.input.onchange = () => this.value.set(this.input.valueAsNumber); this.input.onchange = () => (this.value.val = this.input.valueAsNumber);
this.element.append(this.input); this.element.append(this.input);
@ -57,7 +56,7 @@ export class NumberInput extends LabelledControl {
private set_prop<T>(prop: "min" | "max" | "step", value: T | Property<T>): void { private set_prop<T>(prop: "min" | "max" | "step", value: T | Property<T>): void {
if (is_any_property(value)) { if (is_any_property(value)) {
this.input[prop] = String(value.get()); this.input[prop] = String(value.val);
this.disposable(value.observe(v => (this.input[prop] = String(v)))); this.disposable(value.observe(v => (this.input[prop] = String(v))));
} else { } else {
this.input[prop] = String(value); this.input[prop] = String(value);

View File

@ -1,9 +1,9 @@
import { ResizableView } from "./ResizableView"; import { ResizableView } from "./ResizableView";
import { create_el } from "./dom"; import { el } from "./dom";
import { Renderer } from "../rendering/Renderer"; import { Renderer } from "../rendering/Renderer";
export class RendererView extends ResizableView { export class RendererView extends ResizableView {
readonly element = create_el("div"); readonly element = el("div");
constructor(private renderer: Renderer) { constructor(private renderer: Renderer) {
super(); super();

View File

@ -1,6 +1,6 @@
.core_TabContainer_Bar { .core_TabContainer_Bar {
box-sizing: border-box; box-sizing: border-box;
padding: 3px 0 0 0; padding: 3px 3px 0 3px;
border-bottom: solid 1px var(--border-color); border-bottom: solid 1px var(--border-color);
} }

View File

@ -1,5 +1,5 @@
import { View } from "./View"; import { View } from "./View";
import { create_el } from "./dom"; import { el } from "./dom";
import { LazyView } from "./LazyView"; import { LazyView } from "./LazyView";
import { Resizable } from "./Resizable"; import { Resizable } from "./Resizable";
import { ResizableView } from "./ResizableView"; import { ResizableView } from "./ResizableView";
@ -16,11 +16,11 @@ type TabInfo = Tab & { tab_element: HTMLSpanElement; lazy_view: LazyView };
const BAR_HEIGHT = 28; const BAR_HEIGHT = 28;
export class TabContainer extends ResizableView { export class TabContainer extends ResizableView {
readonly element = create_el("div", "core_TabContainer"); readonly element = el("div", { class: "core_TabContainer" });
private tabs: TabInfo[] = []; private tabs: TabInfo[] = [];
private bar_element = create_el("div", "core_TabContainer_Bar"); private bar_element = el("div", { class: "core_TabContainer_Bar" });
private panes_element = create_el("div", "core_TabContainer_Panes"); private panes_element = el("div", { class: "core_TabContainer_Panes" });
constructor(...tabs: Tab[]) { constructor(...tabs: Tab[]) {
super(); super();
@ -28,9 +28,10 @@ export class TabContainer extends ResizableView {
this.bar_element.onclick = this.bar_click; this.bar_element.onclick = this.bar_click;
for (const tab of tabs) { for (const tab of tabs) {
const tab_element = create_el("span", "core_TabContainer_Tab", tab_element => { const tab_element = el("span", {
tab_element.textContent = tab.title; class: "core_TabContainer_Tab",
tab_element.dataset["key"] = tab.key; text: tab.title,
data: { key: tab.key },
}); });
this.bar_element.append(tab_element); this.bar_element.append(tab_element);

View File

@ -1,10 +1,10 @@
import { View } from "./View"; import { View } from "./View";
import { create_el } from "./dom"; import { el } from "./dom";
import "./ToolBar.css"; import "./ToolBar.css";
import { LabelledControl } from "./LabelledControl"; import { LabelledControl } from "./LabelledControl";
export class ToolBar extends View { export class ToolBar extends View {
readonly element = create_el("div", "core_ToolBar"); readonly element = el("div", { class: "core_ToolBar" });
readonly height = 33; readonly height = 33;
constructor(...children: View[]) { constructor(...children: View[]) {
@ -14,7 +14,7 @@ export class ToolBar extends View {
for (const child of children) { for (const child of children) {
if (child instanceof LabelledControl) { if (child instanceof LabelledControl) {
const group = create_el("div", "core_ToolBar_group"); const group = el("div", { class: "core_ToolBar_group" });
if (child.preferred_label_position === "left") { if (child.preferred_label_position === "left") {
group.append(child.label.element, child.element); group.append(child.label.element, child.element);

View File

@ -1,5 +1,7 @@
import { Disposable } from "../observable/Disposable"; import { Disposable } from "../observable/Disposable";
import { Disposer } from "../observable/Disposer"; import { Disposer } from "../observable/Disposer";
import { Observable } from "../observable/Observable";
import { bind_hidden } from "./dom";
export abstract class View implements Disposable { export abstract class View implements Disposable {
abstract readonly element: HTMLElement; abstract readonly element: HTMLElement;
@ -14,6 +16,19 @@ export abstract class View implements Disposable {
private disposer = new Disposer(); private disposer = new Disposer();
dispose(): void {
this.element.remove();
this.disposer.dispose();
}
protected bind_hidden(element: HTMLElement, observable: Observable<boolean>): void {
this.disposable(bind_hidden(element, observable));
}
protected bind_disabled(element: HTMLElement, observable: Observable<boolean>): void {
this.disposable(bind_hidden(element, observable));
}
protected disposable<T extends Disposable>(disposable: T): T { protected disposable<T extends Disposable>(disposable: T): T {
return this.disposer.add(disposable); return this.disposer.add(disposable);
} }
@ -21,9 +36,4 @@ export abstract class View implements Disposable {
protected disposables(...disposables: Disposable[]): void { protected disposables(...disposables: Disposable[]): void {
this.disposer.add_all(...disposables); this.disposer.add_all(...disposables);
} }
dispose(): void {
this.element.remove();
this.disposer.dispose();
}
} }

View File

@ -1,20 +1,38 @@
import { Disposable } from "../observable/Disposable"; import { Disposable } from "../observable/Disposable";
import { Observable } from "../observable/Observable";
import { is_property } from "../observable/Property";
export function create_el<T extends HTMLElement>( export function el<T extends HTMLElement>(
tag_name: string, tag_name: string,
class_name?: string, attributes?: {
modify?: (element: T) => void, class?: string;
text?: string ;
data?: { [key: string]: string };
},
...children: HTMLElement[]
): T { ): T {
const element = document.createElement(tag_name) as T; const element = document.createElement(tag_name) as T;
if (class_name) element.className = class_name;
if (modify) modify(element); if (attributes) {
if (attributes.class) element.className = attributes.class;
if (attributes.text) element.textContent = attributes.text;
if (attributes.data) {
for (const [key, val] of Object.entries(attributes.data)) {
element.dataset[key] = val;
}
}
}
element.append(...children);
return element; return element;
} }
export function disposable_el(element: HTMLElement): Disposable { export function bind_hidden(element: HTMLElement, observable: Observable<boolean>): Disposable {
return { if (is_property(observable)) {
dispose(): void { element.hidden = observable.val;
element.remove(); }
},
}; return observable.observe(v => (element.hidden = v));
} }

View File

@ -0,0 +1,52 @@
#root .lm_header {
box-sizing: border-box;
padding: 3px 0 0 0;
border-bottom: solid 1px var(--border-color);
}
#root .lm_tabs {
padding: 0 3px;
}
#root .lm_tab {
cursor: default;
height: 21px;
line-height: 22px;
padding: 0 10px;
border: solid 1px var(--border-color);
margin: 0 1px -1px 1px;
background-color: hsl(0, 0%, 12%);
color: hsl(0, 0%, 75%);
font-size: 15px;
}
#root .lm_tab:hover {
background-color: hsl(0, 0%, 18%);
color: hsl(0, 0%, 85%);
}
#root .lm_tab.lm_active {
background-color: var(--bg-color);
color: hsl(0, 0%, 90%);
border-bottom-color: var(--bg-color);
}
#root .lm_splitter {
box-sizing: border-box;
background-color: hsl(0, 0%, 20%);
}
#root .lm_splitter.lm_vertical {
border-top: solid 1px var(--border-color);
border-bottom: solid 1px var(--border-color);
}
#root .lm_splitter.lm_horizontal {
border-left: solid 1px var(--border-color);
border-right: solid 1px var(--border-color);
}
body .lm_dropTargetIndicator {
box-sizing: border-box;
background-color: hsla(0, 0%, 100%, 0.2);
}

View File

@ -0,0 +1,45 @@
import { Property } from "./Property";
import { Disposable } from "./Disposable";
import Logger from "js-logger";
const logger = Logger.get("core/observable/AbstractMinimalProperty");
// This class exists purely because otherwise the resulting cyclic dependency graph would trip up commonjs.
// The dependency graph is still cyclic but for some reason it's not a problem this way.
export abstract class AbstractMinimalProperty<T> implements Property<T> {
readonly is_property = true;
abstract readonly val: T;
protected readonly observers: ((value: T) => void)[] = [];
observe(observer: (value: T) => void): Disposable {
if (!this.observers.includes(observer)) {
this.observers.push(observer);
}
return {
dispose: () => {
const index = this.observers.indexOf(observer);
if (index !== -1) {
this.observers.splice(index, 1);
}
},
};
}
abstract map<U>(f: (element: T) => U): Property<U>;
abstract flat_map<U>(f: (element: T) => Property<U>): Property<U>;
protected emit(): void {
for (const observer of this.observers) {
try {
observer(this.val);
} catch (e) {
logger.error("Observer threw error.", e);
}
}
}
}

View File

@ -0,0 +1,14 @@
import { Property } from "./Property";
import { DependentProperty } from "./DependentProperty";
import { FlatMappedProperty } from "./FlatMappedProperty";
import { AbstractMinimalProperty } from "./AbstractMinimalProperty";
export abstract class AbstractProperty<T> extends AbstractMinimalProperty<T> {
map<U>(f: (element: T) => U): Property<U> {
return new DependentProperty([this], () => f(this.val));
}
flat_map<U>(f: (element: T) => Property<U>): Property<U> {
return new FlatMappedProperty(this, value => f(value));
}
}

View File

@ -0,0 +1,7 @@
import { Property } from "./Property";
export interface ArrayProperty<T> extends Property<T[]> {
get(index: number): T;
readonly length: Property<number>;
}

View File

@ -0,0 +1,65 @@
import { Disposable } from "./Disposable";
import { Property } from "./Property";
import { Disposer } from "./Disposer";
import { AbstractMinimalProperty } from "./AbstractMinimalProperty";
import { FlatMappedProperty } from "./FlatMappedProperty";
/**
* Starts observing its dependencies when the first observer on this property is registered.
* Stops observing its dependencies when the last observer on this property is disposed.
* This way no extra disposables need to be managed when e.g. {@link Property.map} is used.
*/
export class DependentProperty<T> extends AbstractMinimalProperty<T> implements Property<T> {
readonly is_property = true;
private _val?: T;
get val(): T {
if (this.dependency_disposables) {
return this._val as T;
} else {
return this.f();
}
}
private dependency_disposables = new Disposer();
constructor(private dependencies: Property<any>[], private f: () => T) {
super();
}
observe(observer: (event: T) => void): Disposable {
const super_disposable = super.observe(observer);
if (this.dependency_disposables.length === 0) {
this._val = this.f();
this.dependency_disposables.add_all(
...this.dependencies.map(dependency =>
dependency.observe(() => {
this._val = this.f();
this.emit();
}),
),
);
}
return {
dispose: () => {
super_disposable.dispose();
if (this.observers.length === 0) {
this.dependency_disposables.dispose();
}
},
};
}
map<U>(f: (element: T) => U): Property<U> {
return new DependentProperty([this], () => f(this.val));
}
flat_map<U>(f: (element: T) => Property<U>): Property<U> {
return new FlatMappedProperty(this, value => f(value));
}
}

View File

@ -6,6 +6,10 @@ const logger = Logger.get("core/observable/Disposer");
export class Disposer implements Disposable { export class Disposer implements Disposable {
private readonly disposables: Disposable[] = []; private readonly disposables: Disposable[] = [];
get length(): number {
return this.disposables.length;
}
add<T extends Disposable>(disposable: T): T { add<T extends Disposable>(disposable: T): T {
this.disposables.push(disposable); this.disposables.push(disposable);
return disposable; return disposable;

View File

@ -1,5 +1,5 @@
import { Observable } from "./Observable"; import { Observable } from "./Observable";
export interface Emitter<E, M> extends Observable<E, M> { export interface Emitter<E> extends Observable<E> {
emit(event: E, meta: M): void; emit(event: E): void;
} }

View File

@ -0,0 +1,70 @@
import { Property } from "./Property";
import { Disposable } from "./Disposable";
import { AbstractMinimalProperty } from "./AbstractMinimalProperty";
import { DependentProperty } from "./DependentProperty";
/**
* Starts observing its dependency when the first observer on this property is registered.
* Stops observing its dependency when the last observer on this property is disposed.
* This way no extra disposables need to be managed when {@link Property.flat_map} is used.
*/
export class FlatMappedProperty<T, U> extends AbstractMinimalProperty<U> implements Property<U> {
readonly is_property = true;
get val(): U {
return this.computed_property
? this.computed_property.val
: this.f(this.dependency.val).val;
}
private dependency_disposable?: Disposable;
private computed_property?: Property<U>;
private computed_disposable?: Disposable;
constructor(private dependency: Property<T>, private f: (value: T) => Property<U>) {
super();
}
observe(observer: (value: U) => void): Disposable {
const super_disposable = super.observe(observer);
if (this.dependency_disposable == undefined) {
this.dependency_disposable = this.dependency.observe(() => {
this.compute_and_observe();
this.emit();
});
this.compute_and_observe();
}
return {
dispose: () => {
super_disposable.dispose();
if (this.observers.length === 0) {
this.dependency_disposable!.dispose();
this.dependency_disposable = undefined;
this.computed_disposable!.dispose();
this.computed_disposable = undefined;
this.computed_property = undefined;
}
},
};
}
map<V>(f: (element: U) => V): Property<V> {
return new DependentProperty([this], () => f(this.val));
}
flat_map<V>(f: (element: U) => Property<V>): Property<V> {
return new FlatMappedProperty(this, value => f(value));
}
private compute_and_observe(): void {
if (this.computed_disposable) this.computed_disposable.dispose();
this.computed_property = this.f(this.dependency.val);
this.computed_disposable = this.computed_property.observe(() => {
this.emit();
});
}
}

View File

@ -1,57 +0,0 @@
import { SimpleEmitter } from "./SimpleEmitter";
import { Disposable } from "./Disposable";
import { Property, PropertyMeta } from "./Property";
/**
* Starts observing its origin when the first observer on this property is registered.
* Stops observing its origin when the last observer on this property is disposed.
* This way no extra disposables need to be managed when {@link Property.map} is used.
*/
export class MappedProperty<S, T> extends SimpleEmitter<T, PropertyMeta<T>> implements Property<T> {
readonly is_property = true;
private origin_disposable?: Disposable;
private value?: T;
constructor(private origin: Property<S>, private f: (value: S) => T) {
super();
}
observe(observer: (event: T, meta: PropertyMeta<T>) => void): Disposable {
const disposable = super.observe(observer);
if (this.origin_disposable == undefined) {
this.value = this.f(this.origin.get());
this.origin_disposable = this.origin.observe(origin_value => {
const old_value = this.value as T;
this.value = this.f(origin_value);
this.emit(this.value, { old_value });
});
}
return {
dispose: () => {
disposable.dispose();
if (this.observers.length === 0) {
this.origin_disposable!.dispose();
this.origin_disposable = undefined;
}
},
};
}
get(): T {
if (this.origin_disposable) {
return this.value as T;
} else {
return this.f(this.origin.get());
}
}
map<U>(f: (element: T) => U): Property<U> {
return new MappedProperty(this, f);
}
}

View File

@ -1,5 +1,5 @@
import { Disposable } from "./Disposable"; import { Disposable } from "./Disposable";
export interface Observable<E, M = undefined> { export interface Observable<E> {
observe(observer: (event: E, meta: M) => void): Disposable; observe(observer: (event: E) => void): Disposable;
} }

View File

@ -1,16 +1,16 @@
import { Observable } from "./Observable"; import { Observable } from "./Observable";
export interface Property<T> extends Observable<T, PropertyMeta<T>> { export interface Property<T> extends Observable<T> {
readonly is_property: true; readonly is_property: true;
get(): T; readonly val: T;
map<U>(f: (element: T) => U): Property<U>; map<U>(f: (element: T) => U): Property<U>;
flat_map<U>(f: (element: T) => Property<U>): Property<U>;
} }
export type PropertyMeta<T> = { old_value: T }; export function is_property<T>(observable: Observable<T>): observable is Property<T> {
export function is_property<T>(observable: Observable<T, any>): observable is Property<T> {
return (observable as any).is_property; return (observable as any).is_property;
} }

View File

@ -3,20 +3,20 @@ import Logger from "js-logger";
const logger = Logger.get("core/observable/SimpleEmitter"); const logger = Logger.get("core/observable/SimpleEmitter");
export class SimpleEmitter<E, M = undefined> { export class SimpleEmitter<E> {
protected readonly observers: ((event: E, meta: M) => void)[] = []; protected readonly observers: ((event: E) => void)[] = [];
emit(event: E, meta: M): void { emit(event: E): void {
for (const observer of this.observers) { for (const observer of this.observers) {
try { try {
observer(event, meta); observer(event);
} catch (e) { } catch (e) {
logger.error("Observer threw error.", e); logger.error("Observer threw error.", e);
} }
} }
} }
observe(observer: (event: E, meta: M) => void): Disposable { observe(observer: (event: E) => void): Disposable {
if (!this.observers.includes(observer)) { if (!this.observers.includes(observer)) {
this.observers.push(observer); this.observers.push(observer);
} }

View File

@ -1,40 +1,37 @@
import { SimpleEmitter } from "./SimpleEmitter";
import { Disposable } from "./Disposable"; import { Disposable } from "./Disposable";
import { Observable } from "./Observable"; import { Observable } from "./Observable";
import { WritableProperty } from "./WritableProperty"; import { WritableProperty } from "./WritableProperty";
import { Property, PropertyMeta, is_property } from "./Property"; import { is_property } from "./Property";
import { MappedProperty } from "./MappedProperty"; import { AbstractProperty } from "./AbstractProperty";
export class SimpleProperty<T> extends SimpleEmitter<T, PropertyMeta<T>> export class SimpleProperty<T> extends AbstractProperty<T> implements WritableProperty<T> {
implements WritableProperty<T> {
readonly is_property = true;
readonly is_writable_property = true; readonly is_writable_property = true;
private value: T; constructor(private _val: T) {
constructor(value: T) {
super(); super();
this.value = value;
} }
get(): T { get val(): T {
return this.value; return this._val;
} }
set(value: T): void { set val(val: T) {
if (value !== this.value) { if (val !== this._val) {
const old_value = this.value; this._val = val;
this.value = value; this.emit();
this.emit(value, { old_value });
} }
} }
bind(observable: Observable<T, any>): Disposable { update(f: (value: T) => T): void {
this.val = f(this.val);
}
bind(observable: Observable<T>): Disposable {
if (is_property(observable)) { if (is_property(observable)) {
this.set(observable.get()); this.val = observable.val;
} }
return observable.observe(v => this.set(v)); return observable.observe(v => (this.val = v));
} }
bind_bi(property: WritableProperty<T>): Disposable { bind_bi(property: WritableProperty<T>): Disposable {
@ -47,8 +44,4 @@ export class SimpleProperty<T> extends SimpleEmitter<T, PropertyMeta<T>>
}, },
}; };
} }
map<U>(f: (element: T) => U): Property<U> {
return new MappedProperty(this, f);
}
} }

View File

@ -0,0 +1,70 @@
/* eslint-disable no-dupe-class-members */
import { WritableArrayProperty } from "./WritableArrayProperty";
import { Disposable } from "./Disposable";
import { WritableProperty } from "./WritableProperty";
import { Observable } from "./Observable";
import { property } from "./index";
import { AbstractProperty } from "./AbstractProperty";
export class SimpleWritableArrayProperty<T> extends AbstractProperty<T[]>
implements WritableArrayProperty<T> {
readonly is_property = true;
readonly is_writable_property = true;
private readonly _length = property(0);
readonly length = this._length;
private readonly values: T[];
get val(): T[] {
return this.values;
}
constructor(...values: T[]) {
super();
this.values = values;
}
bind(observable: Observable<T[]>): Disposable {
/* TODO */ throw new Error("not implemented");
}
bind_bi(property: WritableProperty<T[]>): Disposable {
/* TODO */ throw new Error("not implemented");
}
update(f: (value: T[]) => T[]): void {
this.values.splice(0, this.values.length, ...f(this.values));
}
get(index: number): T {
return this.values[index];
}
set(index: number, value: T): void {
this.values[index] = value;
this.emit();
}
clear(): void {
this.values.splice(0, this.values.length);
this.emit();
}
splice(index: number, delete_count?: number): T[];
splice(index: number, delete_count: number, ...items: T[]): T[];
splice(index: number, delete_count?: number, ...items: T[]): T[] {
let ret: T[];
if (delete_count == undefined) {
ret = this.values.splice(index);
} else {
ret = this.values.splice(index, delete_count, ...items);
}
this.emit();
return ret;
}
}

View File

@ -0,0 +1,12 @@
import { WritableProperty } from "./WritableProperty";
import { ArrayProperty } from "./ArrayProperty";
export interface WritableArrayProperty<T> extends ArrayProperty<T>, WritableProperty<T[]> {
val: T[];
set(index: number, value: T): void;
splice(index: number, delete_count?: number, ...items: T[]): T[];
clear(): void;
}

View File

@ -3,17 +3,24 @@ import { Observable } from "./Observable";
import { Disposable } from "./Disposable"; import { Disposable } from "./Disposable";
export interface WritableProperty<T> extends Property<T> { export interface WritableProperty<T> extends Property<T> {
is_writable_property: true; readonly is_writable_property: true;
set(value: T): void; val: T;
bind(observable: Observable<T, any>): Disposable; update(f: (value: T) => T): void;
/**
* Bind the value of this property to the given observable.
*
* @param observable the observable who's events will be propagated to this property.
*/
bind(observable: Observable<T>): Disposable;
bind_bi(property: WritableProperty<T>): Disposable; bind_bi(property: WritableProperty<T>): Disposable;
} }
export function is_writable_property<T>( export function is_writable_property<T>(
observable: Observable<T, any>, observable: Observable<T>,
): observable is WritableProperty<T> { ): observable is WritableProperty<T> {
return (observable as any).is_writable_property; return (observable as any).is_writable_property;
} }

View File

@ -2,11 +2,44 @@ import { SimpleEmitter } from "./SimpleEmitter";
import { WritableProperty } from "./WritableProperty"; import { WritableProperty } from "./WritableProperty";
import { SimpleProperty } from "./SimpleProperty"; import { SimpleProperty } from "./SimpleProperty";
import { Emitter } from "./Emitter"; import { Emitter } from "./Emitter";
import { Property } from "./Property";
import { DependentProperty } from "./DependentProperty";
import { WritableArrayProperty } from "./WritableArrayProperty";
import { SimpleWritableArrayProperty } from "./SimpleWritableArrayProperty";
export function emitter<E, M = undefined>(): Emitter<E, M> { export function emitter<E>(): Emitter<E> {
return new SimpleEmitter(); return new SimpleEmitter();
} }
export function property<T>(value: T): WritableProperty<T> { export function property<T>(value: T): WritableProperty<T> {
return new SimpleProperty(value); return new SimpleProperty(value);
} }
export function array_property<T>(...values: T[]): WritableArrayProperty<T> {
return new SimpleWritableArrayProperty(...values);
}
export function if_defined<S, T>(
property: Property<S | undefined>,
f: (value: S) => T,
default_value: T,
): T {
const val = property.val;
return val == undefined ? default_value : f(val);
}
export function add(left: Property<number>, right: number): Property<number> {
return left.map(l => l + right);
}
export function sub(left: Property<number>, right: number): Property<number> {
return left.map(l => l - right);
}
export function map<R, S, T>(
f: (prop_1: S, prop_2: T) => R,
prop_1: Property<S>,
prop_2: Property<T>,
): Property<R> {
return new DependentProperty([prop_1, prop_2], () => f(prop_1.val, prop_2.val));
}

View File

@ -24,7 +24,7 @@ class GuiStore implements Disposable {
constructor() { constructor() {
const tool = window.location.hash.slice(2); const tool = window.location.hash.slice(2);
this.tool.set(string_to_gui_tool(tool) || GuiTool.Viewer); this.tool.val = string_to_gui_tool(tool) || GuiTool.Viewer;
} }
dispose(): void { dispose(): void {

7
src/core/undo/Action.ts Normal file
View File

@ -0,0 +1,7 @@
export class Action {
constructor(
readonly description: string,
readonly undo: () => void,
readonly redo: () => void,
) {}
}

View File

@ -0,0 +1,64 @@
import { Undo } from "./Undo";
import { Action } from "./Action";
import { Property } from "../observable/Property";
import { property } from "../observable";
import { NOOP_UNDO } from "./noop_undo";
import { undo_manager } from "./UndoManager";
/**
* Simply contains a single action. `can_undo` and `can_redo` must be managed manually.
*/
export class SimpleUndo implements Undo {
private readonly _action: Action;
readonly action: Property<Action>;
constructor(description: string, undo: () => void, redo: () => void) {
this._action = new Action(description, undo, redo);
this.action = property(this._action);
}
make_current(): void {
undo_manager.current.val = this;
}
ensure_not_current(): void {
if (undo_manager.current.val === this) {
undo_manager.current.val = NOOP_UNDO;
}
}
readonly can_undo = property(false);
readonly can_redo = property(false);
readonly first_undo: Property<Action | undefined> = this.can_undo.map(can_undo =>
can_undo ? this._action : undefined,
);
readonly first_redo: Property<Action | undefined> = this.can_redo.map(can_redo =>
can_redo ? this._action : undefined,
);
undo(): boolean {
if (this.can_undo) {
this._action.undo();
return true;
} else {
return false;
}
}
redo(): boolean {
if (this.can_redo) {
this._action.redo();
return true;
} else {
return false;
}
}
reset(): void {
this.can_undo.val = false;
this.can_redo.val = false;
}
}

28
src/core/undo/Undo.ts Normal file
View File

@ -0,0 +1,28 @@
import { Property } from "../observable/Property";
import { Action } from "./Action";
export interface Undo {
make_current(): void;
ensure_not_current(): void;
readonly can_undo: Property<boolean>;
readonly can_redo: Property<boolean>;
/**
* The first action that will be undone when calling undo().
*/
readonly first_undo: Property<Action | undefined>;
/**
* The first action that will be redone when calling redo().
*/
readonly first_redo: Property<Action | undefined>;
undo(): boolean;
redo(): boolean;
reset(): void;
}

View File

@ -0,0 +1,25 @@
import { if_defined, property } from "../observable";
import { Undo } from "./Undo";
import { NOOP_UNDO } from "./noop_undo";
class UndoManager {
readonly current = property<Undo>(NOOP_UNDO);
can_undo = this.current.flat_map(c => c.can_undo);
can_redo = this.current.flat_map(c => c.can_redo);
first_undo = this.current.flat_map(c => c.first_undo);
first_redo = this.current.flat_map(c => c.first_redo);
undo(): boolean {
return if_defined(this.current, c => c.undo(), false);
}
redo(): boolean {
return if_defined(this.current, c => c.redo(), false);
}
}
export const undo_manager = new UndoManager();

View File

@ -0,0 +1,82 @@
import { Undo } from "./Undo";
import { WritableArrayProperty } from "../observable/WritableArrayProperty";
import { Action } from "./Action";
import { array_property, map, property } from "../observable";
import { NOOP_UNDO } from "./noop_undo";
import { undo_manager } from "./UndoManager";
/**
* Full-fledged linear undo/redo implementation.
*/
export class UndoStack implements Undo {
private readonly stack: WritableArrayProperty<Action> = array_property();
/**
* The index where new actions are inserted.
*/
private readonly index = property(0);
make_current(): void {
undo_manager.current.val = this;
}
ensure_not_current(): void {
if (undo_manager.current.val === this) {
undo_manager.current.val = NOOP_UNDO;
}
}
readonly can_undo = this.index.map(index => index > 0);
readonly can_redo = map((stack, index) => index < stack.length, this.stack, this.index);
readonly first_undo = this.can_undo.map(can_undo => {
return can_undo ? this.stack.get(this.index.val - 1) : undefined;
});
readonly first_redo = this.can_redo.map(can_redo => {
return can_redo ? this.stack.get(this.index.val) : undefined;
});
push_action(description: string, undo: () => void, redo: () => void): void {
this.push(new Action(description, undo, redo));
}
push(action: Action): void {
this.stack.splice(this.index.val, this.stack.length.val - this.index.val, action);
this.index.update(i => i + 1);
}
/**
* Pop an action off the stack without undoing.
*/
pop(): Action | undefined {
this.index.update(i => i - 1);
return this.stack.splice(this.index.val, 1)[0];
}
undo(): boolean {
if (this.can_undo) {
this.index.update(i => i - 1);
this.stack.get(this.index.val).undo();
return true;
} else {
return false;
}
}
redo(): boolean {
if (this.can_redo) {
this.stack.get(this.index.val).redo();
this.index.update(i => i + 1);
return true;
} else {
return false;
}
}
reset(): void {
this.stack.clear();
this.index.val = 0;
}
}

View File

@ -0,0 +1,70 @@
import { Action } from "./Action";
import { UndoStack } from "./UndoStack";
test("simple properties and invariants", () => {
const stack = new UndoStack();
expect(stack.can_undo.val).toBe(false);
expect(stack.can_redo.val).toBe(false);
stack.push(new Action("", () => {}, () => {}));
stack.push(new Action("", () => {}, () => {}));
stack.push(new Action("", () => {}, () => {}));
expect(stack.can_undo.val).toBe(true);
expect(stack.can_redo.val).toBe(false);
stack.undo();
expect(stack.can_undo.val).toBe(true);
expect(stack.can_redo.val).toBe(true);
stack.undo();
stack.undo();
expect(stack.can_undo.val).toBe(false);
expect(stack.can_redo.val).toBe(true);
});
test("undo", () => {
const stack = new UndoStack();
// Pretend value started and 3 and we've set it to 7 and then 13.
let value = 13;
stack.push(new Action("X", () => (value = 3), () => (value = 7)));
stack.push(new Action("Y", () => (value = 7), () => (value = 13)));
expect(stack.undo()).toBe(true);
expect(value).toBe(7);
expect(stack.undo()).toBe(true);
expect(value).toBe(3);
expect(stack.undo()).toBe(false);
expect(value).toBe(3);
});
test("redo", () => {
const stack = new UndoStack();
// Pretend value started and 3 and we've set it to 7 and then 13.
let value = 13;
stack.push(new Action("X", () => (value = 3), () => (value = 7)));
stack.push(new Action("Y", () => (value = 7), () => (value = 13)));
stack.undo();
stack.undo();
expect(value).toBe(3);
expect(stack.redo()).toBe(true);
expect(value).toBe(7);
expect(stack.redo()).toBe(true);
expect(value).toBe(13);
expect(stack.redo()).toBe(false);
expect(value).toBe(13);
});

View File

@ -0,0 +1,26 @@
import { Undo } from "./Undo";
import { property } from "../observable";
import { undo_manager } from "./UndoManager";
export const NOOP_UNDO: Undo = {
can_redo: property(false),
can_undo: property(false),
first_redo: property(undefined),
first_undo: property(undefined),
ensure_not_current() {},
make_current() {
undo_manager.current.val = this;
},
redo() {
return false;
},
reset() {},
undo() {
return false;
},
};

View File

@ -0,0 +1,28 @@
import { property } from "../../core/observable";
import { WritableProperty } from "../../core/observable/WritableProperty";
import { Episode } from "../../core/data_formats/parsing/quest/Episode";
export class ObservableQuest {
readonly id: WritableProperty<number>;
readonly language: WritableProperty<number>;
readonly name: WritableProperty<string>;
readonly short_description: WritableProperty<string>;
readonly long_description: WritableProperty<string>;
readonly episode: Episode;
constructor(
id: number,
language: number,
name: string,
short_description: string,
long_description: string,
episode: Episode,
) {
this.id = property(id);
this.language = property(language);
this.name = property(name);
this.short_description = property(short_description);
this.long_description = property(long_description);
this.episode = episode;
}
}

View File

@ -0,0 +1,9 @@
import { EntityType } from "../../core/data_formats/parsing/quest/entities";
export class ObservableQuestEntity<Type extends EntityType = EntityType> {
readonly type: Type;
constructor(type: Type) {
this.type = type;
}
}

View File

@ -0,0 +1,8 @@
import { ObservableQuestEntity } from "./ObservableQuestEntity";
import { NpcType } from "../../core/data_formats/parsing/quest/npc_types";
export class ObservableQuestNpc extends ObservableQuestEntity<NpcType> {
constructor(type: NpcType) {
super(type);
}
}

View File

@ -0,0 +1,8 @@
import { ObservableQuestEntity } from "./ObservableQuestEntity";
import { ObjectType } from "../../core/data_formats/parsing/quest/object_types";
export class ObservableQuestObject extends ObservableQuestEntity<ObjectType> {
constructor(type: ObjectType) {
super(type);
}
}

View File

@ -0,0 +1,6 @@
import { ResizableView } from "../../core/gui/ResizableView";
import { el } from "../../core/gui/dom";
export class NpcCountsView extends ResizableView {
readonly element = el("div");
}

View File

@ -0,0 +1,39 @@
import { ResizableView } from "../../core/gui/ResizableView";
import { el } from "../../core/gui/dom";
import { quest_editor_store } from "../stores/QuestEditorStore";
import { Episode } from "../../core/data_formats/parsing/quest/Episode";
export class QuesInfoView extends ResizableView {
readonly element = el("div", { class: "quest_editor_QuesInfoView" });
private readonly table_element = el("table");
private readonly episode_element: HTMLElement;
private readonly id_element: HTMLElement;
private readonly name_element: HTMLElement;
constructor() {
super();
const quest = quest_editor_store.current_quest;
this.bind_hidden(this.table_element, quest.map(q => q == undefined));
this.table_element.append(
el("tr", {}, el("th", { text: "Episode:" }), (this.episode_element = el("td"))),
el("tr", {}, el("th", { text: "ID:" }), (this.id_element = el("td"))),
el("tr", {}, el("th", { text: "Name:" }), (this.name_element = el("td"))),
);
this.element.append(this.table_element);
this.disposables(
quest.observe(q => {
if (q) {
this.episode_element.textContent = Episode[q.episode];
this.id_element.textContent = q.id.val.toString();
this.name_element.textContent = q.name.val;
}
}),
);
}
}

View File

@ -1,20 +1,33 @@
import { ResizableView } from "../../core/gui/ResizableView"; import { ResizableView } from "../../core/gui/ResizableView";
import { create_el } from "../../core/gui/dom"; import { el } from "../../core/gui/dom";
import { ToolBarView } from "./ToolBarView"; import { ToolBarView } from "./ToolBarView";
import GoldenLayout, { ContentItem } from "golden-layout"; import GoldenLayout, { Container, ContentItem, ItemConfigType } from "golden-layout";
import { quest_editor_ui_persister } from "../persistence/QuestEditorUiPersister"; import { quest_editor_ui_persister } from "../persistence/QuestEditorUiPersister";
import { AssemblyEditorComponent } from "../../old/quest_editor/ui/AssemblyEditorComponent"; import { QuesInfoView } from "./QuesInfoView";
import { quest_editor_store } from "../../old/quest_editor/stores/QuestEditorStore";
import Logger = require("js-logger"); import Logger = require("js-logger");
import "golden-layout/src/css/goldenlayout-base.css";
import "../../core/gui/golden_layout_theme.css";
import { NpcCountsView } from "./NpcCountsView";
const logger = Logger.get("quest_editor/gui/QuestEditorView"); const logger = Logger.get("quest_editor/gui/QuestEditorView");
// Don't change these values, as they are persisted in the user's browser.
const VIEW_TO_NAME = new Map([
[QuesInfoView, "quest_info"],
[NpcCountsView, "npc_counts"],
// [QuestRendererView, "quest_renderer"],
// [AssemblyEditorView, "assembly_editor"],
// [EntityInfoView, "entity_info"],
// [AddObjectView, "add_object"],
]);
const DEFAULT_LAYOUT_CONFIG = { const DEFAULT_LAYOUT_CONFIG = {
settings: { settings: {
showPopoutIcon: false, showPopoutIcon: false,
showMaximiseIcon: false,
}, },
dimensions: { dimensions: {
headerHeight: 28, headerHeight: 22,
}, },
labels: { labels: {
close: "Close", close: "Close",
@ -24,53 +37,135 @@ const DEFAULT_LAYOUT_CONFIG = {
}, },
}; };
const DEFAULT_LAYOUT_CONTENT: ItemConfigType[] = [
{
type: "row",
content: [
{
type: "stack",
width: 3,
content: [
{
title: "Info",
type: "component",
componentName: VIEW_TO_NAME.get(QuesInfoView),
isClosable: false,
},
{
title: "NPC Counts",
type: "component",
componentName: VIEW_TO_NAME.get(NpcCountsView),
isClosable: false,
},
],
},
// {
// type: "stack",
// width: 9,
// content: [
// {
// title: "3D View",
// type: "component",
// componentName: Component.QuestRenderer,
// isClosable: false,
// },
// {
// title: "Script",
// type: "component",
// componentName: Component.AssemblyEditor,
// isClosable: false,
// },
// ],
// },
// {
// title: "Entity",
// type: "component",
// componentName: Component.EntityInfo,
// isClosable: false,
// width: 2,
// },
],
},
];
export class QuestEditorView extends ResizableView { export class QuestEditorView extends ResizableView {
readonly element = create_el("div"); readonly element = el("div", { class: "quest_editor_QuestEditorView" });
private readonly tool_bar_view = this.disposable(new ToolBarView()); private readonly tool_bar_view = this.disposable(new ToolBarView());
private layout_element = create_el("div"); private readonly layout_element = el("div", { class: "quest_editor_gl_container" });
// private layout: GoldenLayout; private readonly layout: Promise<GoldenLayout>;
constructor() { constructor() {
super(); super();
// const content = await quest_editor_ui_persister.load_layout_config( this.element.append(this.tool_bar_view.element, this.layout_element);
// [...CMP_TO_NAME.values()],
// DEFAULT_LAYOUT_CONTENT, this.layout = this.init_golden_layout();
// ); }
//
// const config: GoldenLayout.Config = { resize(width: number, height: number): this {
// ...DEFAULT_LAYOUT_CONFIG, super.resize(width, height);
// content,
// }; const layout_height = Math.max(0, height - this.tool_bar_view.height);
// this.layout_element.style.width = `${width}px`;
// try { this.layout_element.style.height = `${layout_height}px`;
// this.layout = new GoldenLayout(config, this.layout_element); this.layout.then(layout => layout.updateSize(width, layout_height));
// } catch (e) {
// logger.warn("Couldn't initialize golden layout with persisted layout.", e); return this;
// }
// this.layout = new GoldenLayout(
// { dispose(): void {
// ...DEFAULT_LAYOUT_CONFIG, super.dispose();
// content: DEFAULT_LAYOUT_CONTENT, this.layout.then(layout => layout.destroy());
// }, }
// this.layout_element,
// ); private async init_golden_layout(): Promise<GoldenLayout> {
// } const content = await quest_editor_ui_persister.load_layout_config(
// [...VIEW_TO_NAME.values()],
// for (const [component, name] of CMP_TO_NAME) { DEFAULT_LAYOUT_CONTENT,
// this.layout.registerComponent(name, component); );
// }
// try {
// this.layout.on("stateChanged", () => { return this.attempt_gl_init({
// if (this.layout) { ...DEFAULT_LAYOUT_CONFIG,
// quest_editor_ui_persister.persist_layout_config(this.layout.toConfig().content); content,
// } });
// }); } catch (e) {
// logger.warn("Couldn't instantiate golden layout with persisted layout.", e);
// this.layout.on("stackCreated", (stack: ContentItem) => {
// stack.on("activeContentItemChanged", (item: ContentItem) => { return this.attempt_gl_init({
...DEFAULT_LAYOUT_CONFIG,
content: DEFAULT_LAYOUT_CONTENT,
});
}
}
private attempt_gl_init(config: GoldenLayout.Config): GoldenLayout {
const layout = new GoldenLayout(config, this.layout_element);
try {
for (const [view_ctor, name] of VIEW_TO_NAME) {
layout.registerComponent(name, function(container: Container) {
const view = new view_ctor();
container.on("close", () => view.dispose());
container.on("resize", () => view.resize(container.width, container.height));
view.resize(container.width, container.height);
container.getElement().append(view.element);
});
}
layout.on("stateChanged", () => {
if (this.layout) {
quest_editor_ui_persister.persist_layout_config(layout.toConfig().content);
}
});
layout.on("stackCreated", (stack: ContentItem) => {
stack.on("activeContentItemChanged", (item: ContentItem) => {
// if ("component" in item.config) { // if ("component" in item.config) {
// if (item.config.component === CMP_TO_NAME.get(AssemblyEditorComponent)) { // if (item.config.component === CMP_TO_NAME.get(AssemblyEditorComponent)) {
// quest_editor_store.script_undo.make_current(); // quest_editor_store.script_undo.make_current();
@ -78,11 +173,15 @@ export class QuestEditorView extends ResizableView {
// quest_editor_store.undo.make_current(); // quest_editor_store.undo.make_current();
// } // }
// } // }
// }); });
// }); });
//
// this.layout.init();
this.element.append(this.tool_bar_view.element, this.layout_element); layout.init();
return layout;
} catch (e) {
layout.destroy();
throw e;
}
} }
} }

View File

@ -2,6 +2,8 @@ import { View } from "../../core/gui/View";
import { ToolBar } from "../../core/gui/ToolBar"; import { ToolBar } from "../../core/gui/ToolBar";
import { FileButton } from "../../core/gui/FileButton"; import { FileButton } from "../../core/gui/FileButton";
import { Button } from "../../core/gui/Button"; import { Button } from "../../core/gui/Button";
import { quest_editor_store } from "../stores/QuestEditorStore";
import { undo_manager } from "../../core/undo/UndoManager";
export class ToolBarView extends View { export class ToolBarView extends View {
private readonly open_file_button = new FileButton("Open file...", ".qst"); private readonly open_file_button = new FileButton("Open file...", ".qst");
@ -21,4 +23,24 @@ export class ToolBarView extends View {
get height(): number { get height(): number {
return this.tool_bar.height; return this.tool_bar.height;
} }
constructor() {
super();
this.disposables(
this.open_file_button.files.observe(files => {
if (files.length) {
quest_editor_store.open_file(files[0]);
}
}),
this.save_as_button.enabled.bind(
quest_editor_store.current_quest.map(q => q != undefined),
),
this.undo_button.enabled.bind(undo_manager.can_undo),
this.redo_button.enabled.bind(undo_manager.can_redo),
);
}
} }

View File

@ -4,7 +4,7 @@ import GoldenLayout from "golden-layout";
const LAYOUT_CONFIG_KEY = "QuestEditorUiPersister.layout_config"; const LAYOUT_CONFIG_KEY = "QuestEditorUiPersister.layout_config";
class QuestEditorUiPersister extends Persister { export class QuestEditorUiPersister extends Persister {
persist_layout_config = throttle( persist_layout_config = throttle(
(config: any) => { (config: any) => {
this.persist(LAYOUT_CONFIG_KEY, config); this.persist(LAYOUT_CONFIG_KEY, config);
@ -51,11 +51,11 @@ class QuestEditorUiPersister extends Persister {
return false; return false;
} }
if ("component" in config) { if ("componentName" in config) {
if (!components.has(config.component)) { if (!components.has(config.componentName)) {
return false; return false;
} else { } else {
found.add(config.component); found.add(config.componentName);
} }
} }

View File

@ -0,0 +1,122 @@
import { property } from "../../core/observable";
import { ObservableQuest } from "../domain/ObservableQuest";
import { Property } from "../../core/observable/Property";
import { read_file } from "../../core/read_file";
import { parse_quest } from "../../core/data_formats/parsing/quest";
import { ArrayBufferCursor } from "../../core/data_formats/cursor/ArrayBufferCursor";
import { Endianness } from "../../core/data_formats/Endianness";
import { SimpleUndo, UndoStack } from "../../old/core/undo";
import Logger = require("js-logger");
const logger = Logger.get("quest_editor/gui/QuestEditorStore");
export class QuestEditorStore {
readonly undo = new UndoStack();
readonly script_undo = new SimpleUndo("Text edits", () => {}, () => {});
private readonly _current_quest = property<ObservableQuest | undefined>(undefined);
readonly current_quest: Property<ObservableQuest | undefined> = this._current_quest;
// TODO: notify user of problems.
open_file = async (file: File) => {
try {
const buffer = await read_file(file);
const quest = parse_quest(new ArrayBufferCursor(buffer, Endianness.Little));
this.set_quest(
quest &&
new ObservableQuest(
quest.id,
quest.language,
quest.name,
quest.short_description,
quest.long_description,
quest.episode,
// quest.map_designations,
// quest.objects.map(
// obj =>
// new ObservableQuestObject(
// obj.type,
// obj.id,
// obj.group_id,
// obj.area_id,
// obj.section_id,
// obj.position,
// obj.rotation,
// obj.properties,
// obj.unknown,
// ),
// ),
// quest.npcs.map(
// npc =>
// new ObservableQuestNpc(
// npc.type,
// npc.pso_type_id,
// npc.npc_id,
// npc.script_label,
// npc.roaming,
// npc.area_id,
// npc.section_id,
// npc.position,
// npc.rotation,
// npc.scale,
// npc.unknown,
// ),
// ),
// quest.dat_unknowns,
// quest.object_code,
// quest.shop_items,
),
file.name,
);
} catch (e) {
logger.error("Couldn't read file.", e);
}
};
private set_quest(quest?: ObservableQuest, filename?: string): void {
// this.current_quest_filename = filename;
this.undo.reset();
this.script_undo.reset();
// if (quest) {
// this.current_area = area_store.get_area(quest.episode, 0);
// } else {
// this.current_area = undefined;
// }
if (quest) {
// Load section data.
// for (const variant of quest.area_variants) {
// const sections = yield area_store.get_area_sections(
// quest.episode,
// variant.area.id,
// variant.id,
// );
// variant.sections.replace(sections);
//
// for (const object of quest.objects.filter(o => o.area_id === variant.area.id)) {
// try {
// this.set_section_on_quest_entity(object, sections);
// } catch (e) {
// logger.error(e);
// }
// }
//
// for (const npc of quest.npcs.filter(npc => npc.area_id === variant.area.id)) {
// try {
// this.set_section_on_quest_entity(npc, sections);
// } catch (e) {
// logger.error(e);
// }
// }
// }
} else {
logger.error("Couldn't parse quest file.");
}
// this.selected_entity = undefined;
this._current_quest.val = quest;
}
}
export const quest_editor_store = new QuestEditorStore();

View File

@ -1,4 +1,4 @@
import { create_el } from "../../core/gui/dom"; import { el } from "../../core/gui/dom";
import { ResizableView } from "../../core/gui/ResizableView"; import { ResizableView } from "../../core/gui/ResizableView";
import { ToolBar } from "../../core/gui/ToolBar"; import { ToolBar } from "../../core/gui/ToolBar";
import "./ModelView.css"; import "./ModelView.css";
@ -18,10 +18,10 @@ const MODEL_LIST_WIDTH = 100;
const ANIMATION_LIST_WIDTH = 130; const ANIMATION_LIST_WIDTH = 130;
export class ModelView extends ResizableView { export class ModelView extends ResizableView {
readonly element = create_el("div", "viewer_ModelView"); readonly element = el("div", { class: "viewer_ModelView" });
private tool_bar_view = this.disposable(new ToolBarView()); private tool_bar_view = this.disposable(new ToolBarView());
private container_element = create_el("div", "viewer_ModelView_container"); private container_element = el("div", { class: "viewer_ModelView_container" });
private model_list_view = this.disposable( private model_list_view = this.disposable(
new ModelSelectListView(model_store.models, model_store.current_model), new ModelSelectListView(model_store.models, model_store.current_model),
); );
@ -43,7 +43,7 @@ export class ModelView extends ResizableView {
this.element.append(this.tool_bar_view.element, this.container_element); this.element.append(this.tool_bar_view.element, this.container_element);
model_store.current_model.set(model_store.models[5]); model_store.current_model.val = model_store.models[5];
this.renderer_view.start_rendering(); this.renderer_view.start_rendering();
@ -147,7 +147,7 @@ class ToolBarView extends View {
} }
class ModelSelectListView<T extends { name: string }> extends ResizableView { class ModelSelectListView<T extends { name: string }> extends ResizableView {
element = create_el("ul", "viewer_ModelSelectListView"); element = el("ul", { class: "viewer_ModelSelectListView" });
set borders(borders: boolean) { set borders(borders: boolean) {
if (borders) { if (borders) {
@ -169,10 +169,7 @@ class ModelSelectListView<T extends { name: string }> extends ResizableView {
models.forEach((model, index) => { models.forEach((model, index) => {
this.element.append( this.element.append(
create_el("li", undefined, li => { el("li", { text: model.name, data: { index: index.toString() } }),
li.textContent = model.name;
li.dataset["index"] = index.toString();
}),
); );
}); });
@ -206,7 +203,7 @@ class ModelSelectListView<T extends { name: string }> extends ResizableView {
const index = parseInt(e.target.dataset["index"]!, 10); const index = parseInt(e.target.dataset["index"]!, 10);
this.selected_element = e.target; this.selected_element = e.target;
this.selected.set(this.models[index]); this.selected.val = this.models[index];
} }
}; };
} }

View File

@ -1,4 +1,4 @@
import { create_el } from "../../core/gui/dom"; import { el } from "../../core/gui/dom";
import { ResizableView } from "../../core/gui/ResizableView"; import { ResizableView } from "../../core/gui/ResizableView";
import { FileButton } from "../../core/gui/FileButton"; import { FileButton } from "../../core/gui/FileButton";
import { ToolBar } from "../../core/gui/ToolBar"; import { ToolBar } from "../../core/gui/ToolBar";
@ -8,7 +8,7 @@ import { TextureRenderer } from "../rendering/TextureRenderer";
import { gui_store, GuiTool } from "../../core/stores/GuiStore"; import { gui_store, GuiTool } from "../../core/stores/GuiStore";
export class TextureView extends ResizableView { export class TextureView extends ResizableView {
readonly element = create_el("div", "viewer_TextureView"); readonly element = el("div", { class: "viewer_TextureView" });
private readonly open_file_button = new FileButton("Open file...", ".xvm"); private readonly open_file_button = new FileButton("Open file...", ".xvm");

View File

@ -92,14 +92,14 @@ export class ModelRenderer extends Renderer implements Disposable {
this.animation = undefined; this.animation = undefined;
} }
const nj_data = model_store.current_nj_data.get(); const nj_data = model_store.current_nj_data.val;
if (nj_data) { if (nj_data) {
const { nj_object, has_skeleton } = nj_data; const { nj_object, has_skeleton } = nj_data;
let mesh: Mesh; let mesh: Mesh;
const xvm = model_store.current_xvm.get(); const xvm = model_store.current_xvm.val;
const textures = xvm ? xvm_to_textures(xvm) : undefined; const textures = xvm ? xvm_to_textures(xvm) : undefined;
const materials = const materials =
@ -129,7 +129,7 @@ export class ModelRenderer extends Renderer implements Disposable {
this.scene.add(mesh); this.scene.add(mesh);
this.skeleton_helper = new SkeletonHelper(mesh); this.skeleton_helper = new SkeletonHelper(mesh);
this.skeleton_helper.visible = model_store.show_skeleton.get(); this.skeleton_helper.visible = model_store.show_skeleton.val;
(this.skeleton_helper.material as any).linewidth = 3; (this.skeleton_helper.material as any).linewidth = 3;
this.scene.add(this.skeleton_helper); this.scene.add(this.skeleton_helper);
@ -147,7 +147,7 @@ export class ModelRenderer extends Renderer implements Disposable {
mixer = this.animation.mixer; mixer = this.animation.mixer;
} }
const nj_data = model_store.current_nj_data.get(); const nj_data = model_store.current_nj_data.val;
if (!this.mesh || !(this.mesh instanceof SkinnedMesh) || !nj_motion || !nj_data) return; if (!this.mesh || !(this.mesh instanceof SkinnedMesh) || !nj_motion || !nj_data) return;
@ -195,7 +195,7 @@ export class ModelRenderer extends Renderer implements Disposable {
}; };
private animation_frame_changed = (frame: number) => { private animation_frame_changed = (frame: number) => {
const nj_motion = model_store.current_nj_motion.get(); const nj_motion = model_store.current_nj_motion.val;
if (this.animation && nj_motion) { if (this.animation && nj_motion) {
const frame_count = nj_motion.frame_count; const frame_count = nj_motion.frame_count;
@ -209,7 +209,7 @@ export class ModelRenderer extends Renderer implements Disposable {
private update_animation_frame(): void { private update_animation_frame(): void {
if (this.animation && !this.animation.action.paused) { if (this.animation && !this.animation.action.paused) {
const time = this.animation.action.time; const time = this.animation.action.time;
model_store.animation_frame.set(time * PSO_FRAME_RATE + 1); model_store.animation_frame.val = time * PSO_FRAME_RATE + 1;
} }
} }
} }

View File

@ -91,7 +91,7 @@ export class ModelStore implements Disposable {
const cursor = new ArrayBufferCursor(buffer, Endianness.Little); const cursor = new ArrayBufferCursor(buffer, Endianness.Little);
if (file.name.endsWith(".nj")) { if (file.name.endsWith(".nj")) {
this.current_model.set(undefined); this.current_model.val = undefined;
const nj_object = parse_nj(cursor)[0]; const nj_object = parse_nj(cursor)[0];
@ -101,7 +101,7 @@ export class ModelStore implements Disposable {
has_skeleton: true, has_skeleton: true,
}); });
} else if (file.name.endsWith(".xj")) { } else if (file.name.endsWith(".xj")) {
this.current_model.set(undefined); this.current_model.val = undefined;
const nj_object = parse_xj(cursor)[0]; const nj_object = parse_xj(cursor)[0];
@ -111,18 +111,18 @@ export class ModelStore implements Disposable {
has_skeleton: false, has_skeleton: false,
}); });
} else if (file.name.endsWith(".njm")) { } else if (file.name.endsWith(".njm")) {
this.current_animation.set(undefined); this.current_animation.val = undefined;
this._current_nj_motion.set(undefined); this._current_nj_motion.val = undefined;
const nj_data = this.current_nj_data.get(); const nj_data = this.current_nj_data.val;
if (nj_data) { if (nj_data) {
this._current_nj_motion.set(parse_njm(cursor, nj_data.bone_count)); this.animation_playing.val = true;
this._current_nj_motion.val = parse_njm(cursor, nj_data.bone_count);
} }
} else if (file.name.endsWith(".xvm")) { } else if (file.name.endsWith(".xvm")) {
if (this.current_model) { if (this.current_model) {
const xvm = parse_xvm(cursor); this._current_xvm.val = parse_xvm(cursor);
this._current_xvm.set(xvm);
} }
} else { } else {
logger.error(`Unknown file extension in filename "${file.name}".`); logger.error(`Unknown file extension in filename "${file.name}".`);
@ -133,7 +133,7 @@ export class ModelStore implements Disposable {
}; };
private load_model = async (model?: CharacterClassModel) => { private load_model = async (model?: CharacterClassModel) => {
this.current_animation.set(undefined); this.current_animation.val = undefined;
if (model) { if (model) {
const nj_object = await this.get_nj_object(model); const nj_object = await this.get_nj_object(model);
@ -145,13 +145,13 @@ export class ModelStore implements Disposable {
has_skeleton: true, has_skeleton: true,
}); });
} else { } else {
this._current_nj_data.set(undefined); this._current_nj_data.val = undefined;
} }
}; };
private set_current_nj_data(nj_data: NjData): void { private set_current_nj_data(nj_data: NjData): void {
this._current_xvm.set(undefined); this._current_xvm.val = undefined;
this._current_nj_data.set(nj_data); this._current_nj_data.val = nj_data;
} }
private async get_nj_object(model: CharacterClassModel): Promise<NjObject> { private async get_nj_object(model: CharacterClassModel): Promise<NjObject> {
@ -215,13 +215,13 @@ export class ModelStore implements Disposable {
} }
private load_animation = async (animation?: CharacterClassAnimation) => { private load_animation = async (animation?: CharacterClassAnimation) => {
const nj_data = this.current_nj_data.get(); const nj_data = this.current_nj_data.val;
if (nj_data && animation) { if (nj_data && animation) {
this._current_nj_motion.set(await this.get_nj_motion(animation, nj_data.bone_count)); this._current_nj_motion.val = await this.get_nj_motion(animation, nj_data.bone_count);
this.animation_playing.set(true); this.animation_playing.val = true;
} else { } else {
this._current_nj_motion.set(undefined); this._current_nj_motion.val = undefined;
} }
}; };

View File

@ -15,7 +15,7 @@ export class TextureStore {
load_file = async (file: File) => { load_file = async (file: File) => {
try { try {
const buffer = await read_file(file); const buffer = await read_file(file);
this._current_xvm.set(parse_xvm(new ArrayBufferCursor(buffer, Endianness.Little))); this._current_xvm.val = parse_xvm(new ArrayBufferCursor(buffer, Endianness.Little));
} catch (e) { } catch (e) {
logger.error("Couldn't read file.", e); logger.error("Couldn't read file.", e);
} }