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 { MainContentView } from "./MainContentView";
import { create_el } from "../../core/gui/dom";
import { el } from "../../core/gui/dom";
import { ResizableView } from "../../core/gui/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 main_content_view = this.disposable(new MainContentView());
@ -12,6 +12,8 @@ export class ApplicationView extends ResizableView {
constructor() {
super();
this.element.id = "root";
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 { LazyView } from "../../core/gui/LazyView";
import { ResizableView } from "../../core/gui/ResizableView";
@ -12,7 +12,7 @@ const TOOLS: [GuiTool, () => Promise<ResizableView>][] = [
];
export class MainContentView extends ResizableView {
element = create_el("div", "application_MainContentView");
element = el("div", { class: "application_MainContentView" });
private tool_views = new Map(
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);
}
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;
this.disposable(gui_store.tool.observe(this.tool_changed));
@ -41,9 +41,10 @@ export class MainContentView extends ResizableView {
return this;
}
private tool_changed = (new_tool: GuiTool, { old_value }: { old_value: GuiTool }) => {
const old_view = this.tool_views.get(old_value);
if (old_view) old_view.visible = false;
private tool_changed = (new_tool: GuiTool) => {
for (const tool of this.tool_views.values()) {
tool.visible = false;
}
const new_view = this.tool_views.get(new_tool);
if (new_view) new_view.visible = true;

View File

@ -13,11 +13,12 @@
.application_ToolButton label {
box-sizing: border-box;
display: inline-block;
display: inline-flex;
flex-direction: row;
align-items: center;
font-size: 15px;
height: 100%;
padding: 0 20px;
line-height: 29px;
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 { gui_store, GuiTool } from "../../core/stores/GuiStore";
import { View } from "../../core/gui/View";
@ -10,7 +10,7 @@ const TOOLS: [GuiTool, string][] = [
];
export class NavigationView extends View {
readonly element = create_el("div", "application_NavigationView");
readonly element = el("div", { class: "application_NavigationView" });
readonly height = 30;
@ -28,13 +28,13 @@ export class NavigationView extends View {
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));
}
private click(e: MouseEvent): void {
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 {
element: HTMLElement = create_el("span");
element: HTMLElement = el("span");
private input: HTMLInputElement = create_el("input");
private label: HTMLLabelElement = create_el("label");
private input: HTMLInputElement = el("input");
private label: HTMLLabelElement = el("label");
constructor(tool: GuiTool, text: string) {
super();

View File

@ -1,5 +1,7 @@
.core_Button {
display: inline-block;
display: inline-flex;
flex-direction: row;
align-items: stretch;
box-sizing: border-box;
padding: 0;
border: solid 1px hsl(0, 0%, 10%);
@ -8,11 +10,12 @@
}
.core_Button .core_Button_inner {
display: inline-block;
display: flex;
flex-direction: row;
align-items: center;
box-sizing: border-box;
background-color: hsl(0, 0%, 20%);
height: 24px;
line-height: 17px;
padding: 3px 8px;
border: solid 1px hsl(0, 0%, 35%);
}
@ -28,3 +31,9 @@
border-color: hsl(0, 0%, 30%);
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 { View } from "./View";
import { el } from "./dom";
import "./Button.css";
import { Observable } from "../observable/Observable";
import { emitter } from "../observable";
import { Control } from "./Control";
export class Button extends View {
readonly element: HTMLButtonElement = create_el("button", "core_Button");
export class Button extends Control {
readonly element: HTMLButtonElement = el("button", { class: "core_Button" });
private readonly _click = emitter<MouseEvent>();
readonly click: Observable<MouseEvent> = this._click;
@ -13,11 +13,10 @@ export class Button extends View {
constructor(text: string) {
super();
const inner_element = create_el("span", "core_Button_inner");
inner_element.textContent = text;
this.element.append(el("span", { class: "core_Button_inner", 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 { property } from "../observable";
import { LabelledControl } from "./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);
@ -14,7 +14,7 @@ export class CheckBox extends LabelledControl {
super(label);
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.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.checked.set(checked);
this.checked.val = checked;
}
}

View File

@ -1,17 +1,21 @@
import { create_el } from "./dom";
import { View } from "./View";
import { el } from "./dom";
import "./FileButton.css";
import "./Button.css";
import { property } from "../observable";
import { Property } from "../observable/Property";
import { Control } from "./Control";
export class FileButton extends View {
readonly element: HTMLLabelElement = create_el("label", "core_FileButton core_Button");
export class FileButton extends Control {
readonly element: HTMLLabelElement = el("label", {
class: "core_FileButton core_Button",
});
private readonly _files = property<File[]>([]);
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 = "") {
super();
@ -20,15 +24,28 @@ export class FileButton extends View {
this.input.accept = accept;
this.input.onchange = () => {
if (this.input.files && this.input.files.length) {
this._files.set([...this.input.files!]);
this._files.val = [...this.input.files!];
} else {
this._files.set([]);
this._files.val = [];
}
};
const inner_element = create_el("span", "core_FileButton_inner core_Button_inner");
inner_element.textContent = text;
this.element.append(
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 { create_el } from "./dom";
import { el } from "./dom";
import { WritableProperty } from "../observable/WritableProperty";
import "./Label.css";
import { property } from "../observable";
import { Property } from "../observable/Property";
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) {
this.element.htmlFor = id;
@ -20,7 +20,7 @@ export class Label extends View {
if (typeof text === "string") {
this.element.append(text);
} else {
this.element.append(text.get());
this.element.append(text.val);
this.disposable(text.observe(text => (this.element.textContent = text)));
}

View File

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

View File

@ -1,22 +1,21 @@
import "./NumberInput.css";
import "./Input.css";
import { create_el } from "./dom";
import { el } from "./dom";
import { WritableProperty } from "../observable/WritableProperty";
import { property } from "../observable";
import { LabelledControl } from "./LabelledControl";
import { is_any_property, Property } from "../observable/Property";
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 preferred_label_position = "left";
private readonly input: HTMLInputElement = create_el(
"input",
"core_NumberInput_inner core_Input_inner",
);
private readonly input: HTMLInputElement = el("input", {
class: "core_NumberInput_inner core_Input_inner",
});
constructor(
value = 0,
@ -34,7 +33,7 @@ export class NumberInput extends LabelledControl {
this.set_prop("max", max);
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);
@ -57,7 +56,7 @@ export class NumberInput extends LabelledControl {
private set_prop<T>(prop: "min" | "max" | "step", value: T | Property<T>): void {
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))));
} else {
this.input[prop] = String(value);

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,7 @@
import { Disposable } from "../observable/Disposable";
import { Disposer } from "../observable/Disposer";
import { Observable } from "../observable/Observable";
import { bind_hidden } from "./dom";
export abstract class View implements Disposable {
abstract readonly element: HTMLElement;
@ -14,6 +16,19 @@ export abstract class View implements Disposable {
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 {
return this.disposer.add(disposable);
}
@ -21,9 +36,4 @@ export abstract class View implements Disposable {
protected disposables(...disposables: Disposable[]): void {
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 { 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,
class_name?: string,
modify?: (element: T) => void,
attributes?: {
class?: string;
text?: string ;
data?: { [key: string]: string };
},
...children: HTMLElement[]
): 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;
}
export function disposable_el(element: HTMLElement): Disposable {
return {
dispose(): void {
element.remove();
},
};
export function bind_hidden(element: HTMLElement, observable: Observable<boolean>): Disposable {
if (is_property(observable)) {
element.hidden = observable.val;
}
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 {
private readonly disposables: Disposable[] = [];
get length(): number {
return this.disposables.length;
}
add<T extends Disposable>(disposable: T): T {
this.disposables.push(disposable);
return disposable;

View File

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

View File

@ -1,16 +1,16 @@
import { Observable } from "./Observable";
export interface Property<T> extends Observable<T, PropertyMeta<T>> {
export interface Property<T> extends Observable<T> {
readonly is_property: true;
get(): T;
readonly val: T;
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, any>): observable is Property<T> {
export function is_property<T>(observable: Observable<T>): observable is Property<T> {
return (observable as any).is_property;
}

View File

@ -3,20 +3,20 @@ import Logger from "js-logger";
const logger = Logger.get("core/observable/SimpleEmitter");
export class SimpleEmitter<E, M = undefined> {
protected readonly observers: ((event: E, meta: M) => void)[] = [];
export class SimpleEmitter<E> {
protected readonly observers: ((event: E) => void)[] = [];
emit(event: E, meta: M): void {
emit(event: E): void {
for (const observer of this.observers) {
try {
observer(event, meta);
observer(event);
} catch (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)) {
this.observers.push(observer);
}

View File

@ -1,40 +1,37 @@
import { SimpleEmitter } from "./SimpleEmitter";
import { Disposable } from "./Disposable";
import { Observable } from "./Observable";
import { WritableProperty } from "./WritableProperty";
import { Property, PropertyMeta, is_property } from "./Property";
import { MappedProperty } from "./MappedProperty";
import { is_property } from "./Property";
import { AbstractProperty } from "./AbstractProperty";
export class SimpleProperty<T> extends SimpleEmitter<T, PropertyMeta<T>>
implements WritableProperty<T> {
readonly is_property = true;
export class SimpleProperty<T> extends AbstractProperty<T> implements WritableProperty<T> {
readonly is_writable_property = true;
private value: T;
constructor(value: T) {
constructor(private _val: T) {
super();
this.value = value;
}
get(): T {
return this.value;
get val(): T {
return this._val;
}
set(value: T): void {
if (value !== this.value) {
const old_value = this.value;
this.value = value;
this.emit(value, { old_value });
set val(val: T) {
if (val !== this._val) {
this._val = val;
this.emit();
}
}
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)) {
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 {
@ -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";
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;
}
export function is_writable_property<T>(
observable: Observable<T, any>,
observable: Observable<T>,
): observable is WritableProperty<T> {
return (observable as any).is_writable_property;
}

View File

@ -2,11 +2,44 @@ import { SimpleEmitter } from "./SimpleEmitter";
import { WritableProperty } from "./WritableProperty";
import { SimpleProperty } from "./SimpleProperty";
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();
}
export function property<T>(value: T): WritableProperty<T> {
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() {
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 {

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 { create_el } from "../../core/gui/dom";
import { el } from "../../core/gui/dom";
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 { AssemblyEditorComponent } from "../../old/quest_editor/ui/AssemblyEditorComponent";
import { quest_editor_store } from "../../old/quest_editor/stores/QuestEditorStore";
import { QuesInfoView } from "./QuesInfoView";
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");
// 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 = {
settings: {
showPopoutIcon: false,
showMaximiseIcon: false,
},
dimensions: {
headerHeight: 28,
headerHeight: 22,
},
labels: {
close: "Close",
@ -24,65 +37,151 @@ 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 {
readonly element = create_el("div");
readonly element = el("div", { class: "quest_editor_QuestEditorView" });
private readonly tool_bar_view = this.disposable(new ToolBarView());
private layout_element = create_el("div");
// private layout: GoldenLayout;
private readonly layout_element = el("div", { class: "quest_editor_gl_container" });
private readonly layout: Promise<GoldenLayout>;
constructor() {
super();
// const content = await quest_editor_ui_persister.load_layout_config(
// [...CMP_TO_NAME.values()],
// DEFAULT_LAYOUT_CONTENT,
// );
//
// const config: GoldenLayout.Config = {
// ...DEFAULT_LAYOUT_CONFIG,
// content,
// };
//
// try {
// this.layout = new GoldenLayout(config, this.layout_element);
// } catch (e) {
// logger.warn("Couldn't initialize golden layout with persisted layout.", e);
//
// this.layout = new GoldenLayout(
// {
// ...DEFAULT_LAYOUT_CONFIG,
// content: DEFAULT_LAYOUT_CONTENT,
// },
// this.layout_element,
// );
// }
//
// for (const [component, name] of CMP_TO_NAME) {
// this.layout.registerComponent(name, component);
// }
//
// this.layout.on("stateChanged", () => {
// if (this.layout) {
// quest_editor_ui_persister.persist_layout_config(this.layout.toConfig().content);
// }
// });
//
// this.layout.on("stackCreated", (stack: ContentItem) => {
// stack.on("activeContentItemChanged", (item: ContentItem) => {
// if ("component" in item.config) {
// if (item.config.component === CMP_TO_NAME.get(AssemblyEditorComponent)) {
// quest_editor_store.script_undo.make_current();
// } else {
// quest_editor_store.undo.make_current();
// }
// }
// });
// });
//
// this.layout.init();
this.element.append(this.tool_bar_view.element, this.layout_element);
this.layout = this.init_golden_layout();
}
resize(width: number, height: number): this {
super.resize(width, height);
const layout_height = Math.max(0, height - this.tool_bar_view.height);
this.layout_element.style.width = `${width}px`;
this.layout_element.style.height = `${layout_height}px`;
this.layout.then(layout => layout.updateSize(width, layout_height));
return this;
}
dispose(): void {
super.dispose();
this.layout.then(layout => layout.destroy());
}
private async init_golden_layout(): Promise<GoldenLayout> {
const content = await quest_editor_ui_persister.load_layout_config(
[...VIEW_TO_NAME.values()],
DEFAULT_LAYOUT_CONTENT,
);
try {
return this.attempt_gl_init({
...DEFAULT_LAYOUT_CONFIG,
content,
});
} catch (e) {
logger.warn("Couldn't instantiate golden layout with persisted layout.", e);
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 (item.config.component === CMP_TO_NAME.get(AssemblyEditorComponent)) {
// quest_editor_store.script_undo.make_current();
// } else {
// quest_editor_store.undo.make_current();
// }
// }
});
});
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 { FileButton } from "../../core/gui/FileButton";
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 {
private readonly open_file_button = new FileButton("Open file...", ".qst");
@ -21,4 +23,24 @@ export class ToolBarView extends View {
get height(): number {
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";
class QuestEditorUiPersister extends Persister {
export class QuestEditorUiPersister extends Persister {
persist_layout_config = throttle(
(config: any) => {
this.persist(LAYOUT_CONFIG_KEY, config);
@ -51,11 +51,11 @@ class QuestEditorUiPersister extends Persister {
return false;
}
if ("component" in config) {
if (!components.has(config.component)) {
if ("componentName" in config) {
if (!components.has(config.componentName)) {
return false;
} 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 { ToolBar } from "../../core/gui/ToolBar";
import "./ModelView.css";
@ -18,10 +18,10 @@ const MODEL_LIST_WIDTH = 100;
const ANIMATION_LIST_WIDTH = 130;
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 container_element = create_el("div", "viewer_ModelView_container");
private container_element = el("div", { class: "viewer_ModelView_container" });
private model_list_view = this.disposable(
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);
model_store.current_model.set(model_store.models[5]);
model_store.current_model.val = model_store.models[5];
this.renderer_view.start_rendering();
@ -147,7 +147,7 @@ class ToolBarView extends View {
}
class ModelSelectListView<T extends { name: string }> extends ResizableView {
element = create_el("ul", "viewer_ModelSelectListView");
element = el("ul", { class: "viewer_ModelSelectListView" });
set borders(borders: boolean) {
if (borders) {
@ -169,10 +169,7 @@ class ModelSelectListView<T extends { name: string }> extends ResizableView {
models.forEach((model, index) => {
this.element.append(
create_el("li", undefined, li => {
li.textContent = model.name;
li.dataset["index"] = index.toString();
}),
el("li", { text: model.name, data: { index: index.toString() } }),
);
});
@ -206,7 +203,7 @@ class ModelSelectListView<T extends { name: string }> extends ResizableView {
const index = parseInt(e.target.dataset["index"]!, 10);
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 { FileButton } from "../../core/gui/FileButton";
import { ToolBar } from "../../core/gui/ToolBar";
@ -8,7 +8,7 @@ import { TextureRenderer } from "../rendering/TextureRenderer";
import { gui_store, GuiTool } from "../../core/stores/GuiStore";
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");

View File

@ -92,14 +92,14 @@ export class ModelRenderer extends Renderer implements Disposable {
this.animation = undefined;
}
const nj_data = model_store.current_nj_data.get();
const nj_data = model_store.current_nj_data.val;
if (nj_data) {
const { nj_object, has_skeleton } = nj_data;
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 materials =
@ -129,7 +129,7 @@ export class ModelRenderer extends Renderer implements Disposable {
this.scene.add(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.scene.add(this.skeleton_helper);
@ -147,7 +147,7 @@ export class ModelRenderer extends Renderer implements Disposable {
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;
@ -195,7 +195,7 @@ export class ModelRenderer extends Renderer implements Disposable {
};
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) {
const frame_count = nj_motion.frame_count;
@ -209,7 +209,7 @@ export class ModelRenderer extends Renderer implements Disposable {
private update_animation_frame(): void {
if (this.animation && !this.animation.action.paused) {
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);
if (file.name.endsWith(".nj")) {
this.current_model.set(undefined);
this.current_model.val = undefined;
const nj_object = parse_nj(cursor)[0];
@ -101,7 +101,7 @@ export class ModelStore implements Disposable {
has_skeleton: true,
});
} else if (file.name.endsWith(".xj")) {
this.current_model.set(undefined);
this.current_model.val = undefined;
const nj_object = parse_xj(cursor)[0];
@ -111,18 +111,18 @@ export class ModelStore implements Disposable {
has_skeleton: false,
});
} else if (file.name.endsWith(".njm")) {
this.current_animation.set(undefined);
this._current_nj_motion.set(undefined);
this.current_animation.val = 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) {
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")) {
if (this.current_model) {
const xvm = parse_xvm(cursor);
this._current_xvm.set(xvm);
this._current_xvm.val = parse_xvm(cursor);
}
} else {
logger.error(`Unknown file extension in filename "${file.name}".`);
@ -133,7 +133,7 @@ export class ModelStore implements Disposable {
};
private load_model = async (model?: CharacterClassModel) => {
this.current_animation.set(undefined);
this.current_animation.val = undefined;
if (model) {
const nj_object = await this.get_nj_object(model);
@ -145,13 +145,13 @@ export class ModelStore implements Disposable {
has_skeleton: true,
});
} else {
this._current_nj_data.set(undefined);
this._current_nj_data.val = undefined;
}
};
private set_current_nj_data(nj_data: NjData): void {
this._current_xvm.set(undefined);
this._current_nj_data.set(nj_data);
this._current_xvm.val = undefined;
this._current_nj_data.val = nj_data;
}
private async get_nj_object(model: CharacterClassModel): Promise<NjObject> {
@ -215,13 +215,13 @@ export class ModelStore implements Disposable {
}
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) {
this._current_nj_motion.set(await this.get_nj_motion(animation, nj_data.bone_count));
this.animation_playing.set(true);
this._current_nj_motion.val = await this.get_nj_motion(animation, nj_data.bone_count);
this.animation_playing.val = true;
} 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) => {
try {
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) {
logger.error("Couldn't read file.", e);
}