mirror of
https://github.com/DaanVandenBosch/phantasmal-world.git
synced 2025-04-04 22:58:29 +08:00
Quest editor now uses the Dialog class for the "Save As" dialog so more options can be added to it.
This commit is contained in:
parent
b2e0a612f8
commit
7c9a74171e
@ -38,6 +38,10 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.core_Dialog_footer > * {
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.core_Dialog_modal_overlay {
|
||||
outline: none;
|
||||
z-index: 10;
|
||||
|
@ -1,13 +1,24 @@
|
||||
import { ResizableWidget } from "./ResizableWidget";
|
||||
import { Widget } from "./Widget";
|
||||
import { div, h1, li, section, ul } from "./dom";
|
||||
import { Result } from "../Result";
|
||||
import { Button } from "./Button";
|
||||
import { Widget, WidgetOptions } from "./Widget";
|
||||
import { Child, div, h1, section } from "./dom";
|
||||
import "./Dialog.css";
|
||||
import { is_property, Property } from "../observable/property/Property";
|
||||
import { WidgetProperty } from "../observable/property/WidgetProperty";
|
||||
import { WritableProperty } from "../observable/property/WritableProperty";
|
||||
import { Emitter } from "../observable/Emitter";
|
||||
import { Observable } from "../observable/Observable";
|
||||
import { emitter } from "../observable";
|
||||
|
||||
const DIALOG_WIDTH = 500;
|
||||
const DIALOG_MAX_HEIGHT = 500;
|
||||
|
||||
export type DialogOptions = WidgetOptions & {
|
||||
readonly title?: string | Property<string>;
|
||||
readonly description?: string | Property<string>;
|
||||
readonly content?: Child | Property<Child>;
|
||||
readonly footer?: readonly Child[];
|
||||
};
|
||||
|
||||
/**
|
||||
* A popup window with a title, description, body and dismiss button.
|
||||
*/
|
||||
@ -16,60 +27,75 @@ export class Dialog extends ResizableWidget {
|
||||
private y = 0;
|
||||
private prev_mouse_x = 0;
|
||||
private prev_mouse_y = 0;
|
||||
private readonly overlay_element: HTMLElement;
|
||||
private readonly header_element = h1();
|
||||
private readonly description_element = div({ className: "core_Dialog_description" });
|
||||
private readonly content_element = div({ className: "core_Dialog_body" });
|
||||
private readonly dismiss_button = this.disposable(new Button({ text: "Dismiss" }));
|
||||
private readonly footer_element = div(
|
||||
{ className: "core_Dialog_footer" },
|
||||
this.dismiss_button.element,
|
||||
);
|
||||
|
||||
readonly element: HTMLElement = section(
|
||||
{ className: "core_Dialog", tabIndex: 0 },
|
||||
this.header_element,
|
||||
this.description_element,
|
||||
this.content_element,
|
||||
this.footer_element,
|
||||
);
|
||||
private _title = new WidgetProperty<string>(this, "", this.set_title);
|
||||
private _description = new WidgetProperty<string>(this, "", this.set_description);
|
||||
private _content = new WidgetProperty<Child>(this, "", this.set_content);
|
||||
|
||||
private readonly overlay_element: HTMLElement;
|
||||
private readonly header_element: HTMLElement;
|
||||
private readonly description_element: HTMLElement;
|
||||
private readonly content_element: HTMLElement;
|
||||
|
||||
protected readonly _ondismiss: Emitter<Event> = emitter();
|
||||
|
||||
readonly element: HTMLElement;
|
||||
readonly children: readonly Widget[] = [];
|
||||
|
||||
set title(title: string) {
|
||||
this.header_element.textContent = title;
|
||||
}
|
||||
readonly title: WritableProperty<string> = this._title;
|
||||
readonly description: WritableProperty<string> = this._description;
|
||||
readonly content: WritableProperty<Child> = this._content;
|
||||
|
||||
set description(description: string) {
|
||||
this.description_element.textContent = description;
|
||||
}
|
||||
/**
|
||||
* Emits an event when the user presses the escape key.
|
||||
*/
|
||||
readonly ondismiss: Observable<Event> = this._ondismiss;
|
||||
|
||||
set content(content: Node | string) {
|
||||
this.content_element.textContent = "";
|
||||
this.content_element.append(content);
|
||||
}
|
||||
constructor(options?: DialogOptions) {
|
||||
super(options);
|
||||
|
||||
constructor(title: string = "", description: string = "", content: Node | string = "") {
|
||||
super();
|
||||
|
||||
this.title = title;
|
||||
this.description = description;
|
||||
this.content = content;
|
||||
this.element = section(
|
||||
{ className: "core_Dialog", tabIndex: 0 },
|
||||
(this.header_element = h1()),
|
||||
(this.description_element = div({ className: "core_Dialog_description" })),
|
||||
(this.content_element = div({ className: "core_Dialog_body" })),
|
||||
div({ className: "core_Dialog_footer" }, ...(options?.footer ?? [])),
|
||||
);
|
||||
|
||||
this.element.style.width = `${DIALOG_WIDTH}px`;
|
||||
this.element.style.maxHeight = `${DIALOG_MAX_HEIGHT}px`;
|
||||
|
||||
this.element.addEventListener("keydown", evt => this.keydown(evt));
|
||||
|
||||
if (options) {
|
||||
if (typeof options.title === "string") {
|
||||
this.title.val = options.title;
|
||||
} else if (options.title) {
|
||||
this.title.bind_to(options.title);
|
||||
}
|
||||
|
||||
if (typeof options.description === "string") {
|
||||
this.description.val = options.description;
|
||||
} else if (options.description) {
|
||||
this.description.bind_to(options.description);
|
||||
}
|
||||
|
||||
if (is_property(options.content)) {
|
||||
this.content.bind_to(options.content);
|
||||
} else if (options.content != undefined) {
|
||||
this.content.val = options.content;
|
||||
}
|
||||
}
|
||||
|
||||
this.set_position(
|
||||
(window.innerWidth - DIALOG_WIDTH) / 2,
|
||||
(window.innerHeight - DIALOG_MAX_HEIGHT) / 2,
|
||||
);
|
||||
|
||||
this.element.addEventListener("keydown", this.keydown);
|
||||
this.header_element.addEventListener("mousedown", this.mousedown);
|
||||
|
||||
this.overlay_element = div({ className: "core_Dialog_modal_overlay", tabIndex: -1 });
|
||||
this.overlay_element.addEventListener("focus", () => this.element.focus());
|
||||
|
||||
this.disposables(this.dismiss_button.onclick.observe(() => this.hide()));
|
||||
this.overlay_element.addEventListener("focus", () => this.focus());
|
||||
|
||||
this.finalize_construction();
|
||||
}
|
||||
@ -79,15 +105,26 @@ export class Dialog extends ResizableWidget {
|
||||
this.overlay_element.remove();
|
||||
}
|
||||
|
||||
show(): void {
|
||||
document.body.append(this.overlay_element);
|
||||
document.body.append(this.element);
|
||||
this.focus();
|
||||
focus(): void {
|
||||
(this.first_focusable_child(this.element) || this.element).focus();
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
this.overlay_element.remove();
|
||||
this.element.remove();
|
||||
private first_focusable_child(element: HTMLElement): HTMLElement | undefined {
|
||||
for (const child of element.children) {
|
||||
if (child instanceof HTMLElement) {
|
||||
if (child.tabIndex >= 0) {
|
||||
return child;
|
||||
} else {
|
||||
const element = this.first_focusable_child(child);
|
||||
|
||||
if (element) {
|
||||
return element;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
set_position(x: number, y: number): void {
|
||||
@ -96,6 +133,36 @@ export class Dialog extends ResizableWidget {
|
||||
this.element.style.transform = `translate(${Math.floor(x)}px, ${Math.floor(y)}px)`;
|
||||
}
|
||||
|
||||
protected set_visible(visible: boolean): void {
|
||||
if (visible) {
|
||||
document.body.append(this.overlay_element);
|
||||
document.body.append(this.element);
|
||||
this.focus();
|
||||
} else {
|
||||
this.overlay_element.remove();
|
||||
this.element.remove();
|
||||
}
|
||||
}
|
||||
|
||||
private set_title(title: string): void {
|
||||
this.header_element.textContent = title;
|
||||
}
|
||||
|
||||
private set_description(description: string): void {
|
||||
if (description === "") {
|
||||
this.description_element.hidden = true;
|
||||
this.description_element.textContent = "";
|
||||
} else {
|
||||
this.description_element.hidden = false;
|
||||
this.description_element.textContent = description;
|
||||
}
|
||||
}
|
||||
|
||||
private set_content(content: Child): void {
|
||||
this.content_element.textContent = "";
|
||||
this.content_element.append(content);
|
||||
}
|
||||
|
||||
private mousedown = (evt: MouseEvent): void => {
|
||||
this.prev_mouse_x = evt.clientX;
|
||||
this.prev_mouse_y = evt.clientY;
|
||||
@ -119,42 +186,9 @@ export class Dialog extends ResizableWidget {
|
||||
window.removeEventListener("mouseup", this.window_mouseup);
|
||||
};
|
||||
|
||||
private keydown = (evt: KeyboardEvent): void => {
|
||||
private keydown(evt: KeyboardEvent): void {
|
||||
if (evt.key === "Escape") {
|
||||
this.hide();
|
||||
this._ondismiss.emit({ value: evt });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the details of a result in a dialog window if the result failed or succeeded with problems.
|
||||
*
|
||||
* @param dialog
|
||||
* @param result
|
||||
* @param problems_message - Message to show if problems occurred when result is successful.
|
||||
* @param error_message - Message to show if result failed.
|
||||
*/
|
||||
export function show_result_in_dialog(
|
||||
dialog: Dialog,
|
||||
result: Result<unknown>,
|
||||
problems_message: string,
|
||||
error_message: string,
|
||||
): void {
|
||||
dialog.content = create_result_body(result);
|
||||
|
||||
if (!result.success) {
|
||||
dialog.title = "Error";
|
||||
dialog.description = error_message;
|
||||
dialog.show();
|
||||
} else if (result.problems.length) {
|
||||
dialog.title = "Problems";
|
||||
dialog.description = problems_message;
|
||||
dialog.show();
|
||||
}
|
||||
}
|
||||
|
||||
function create_result_body(result: Result<unknown>): HTMLElement {
|
||||
const body = ul(...result.problems.map(problem => li(problem.ui_message)));
|
||||
body.style.cursor = "text";
|
||||
return body;
|
||||
}
|
||||
|
@ -37,6 +37,11 @@ export abstract class Input<T> extends LabelledControl {
|
||||
this.input_element.addEventListener("change", () => {
|
||||
this._value.set_val(this.get_value(), { silent: false });
|
||||
});
|
||||
this.input_element.addEventListener("keydown", evt => {
|
||||
if (evt.key === "Enter") {
|
||||
this._value.set_val(this.get_value(), { silent: false });
|
||||
}
|
||||
});
|
||||
|
||||
if (options) {
|
||||
if (options.readonly) {
|
||||
|
81
src/core/gui/ResultDialog.ts
Normal file
81
src/core/gui/ResultDialog.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { Dialog } from "./Dialog";
|
||||
import { Button } from "./Button";
|
||||
import { Result } from "../Result";
|
||||
import { is_property, Property } from "../observable/property/Property";
|
||||
import { li, ul } from "./dom";
|
||||
import { property } from "../observable";
|
||||
import { WidgetOptions } from "./Widget";
|
||||
|
||||
/**
|
||||
* Does not inherit {@link Dialog}'s options. The parent class' options are determined by this
|
||||
* class.
|
||||
*/
|
||||
export type ResultDialogOptions = WidgetOptions & {
|
||||
readonly result?: Result<unknown> | Property<Result<unknown> | undefined>;
|
||||
/**
|
||||
* Message to show if problems occurred when result is successful.
|
||||
*/
|
||||
readonly problems_message: string | Property<string>;
|
||||
/**
|
||||
* Message to show if result failed.
|
||||
*/
|
||||
readonly error_message: string | Property<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows the details of a result if the result failed or succeeded with problems. Shows a "Dismiss"
|
||||
* button in the footer which triggers emission of a dismiss event.
|
||||
*/
|
||||
export class ResultDialog extends Dialog {
|
||||
private readonly problems_message: Property<string>;
|
||||
private readonly error_message: Property<string>;
|
||||
|
||||
constructor(options: ResultDialogOptions) {
|
||||
const dismiss_button = new Button({ text: "Dismiss" });
|
||||
|
||||
super({ footer: [dismiss_button.element], ...options });
|
||||
|
||||
const result: Property<Result<unknown> | undefined> = is_property(options.result)
|
||||
? options.result
|
||||
: property(options.result);
|
||||
|
||||
this.problems_message = is_property(options.problems_message)
|
||||
? options.problems_message
|
||||
: property(options.problems_message);
|
||||
|
||||
this.error_message = is_property(options.error_message)
|
||||
? options.error_message
|
||||
: property(options.error_message);
|
||||
|
||||
this.disposables(
|
||||
dismiss_button,
|
||||
dismiss_button.onclick.observe(evt => this._ondismiss.emit(evt)),
|
||||
|
||||
result.observe(({ value }) => this.result_changed(value), { call_now: true }),
|
||||
);
|
||||
|
||||
this.finalize_construction();
|
||||
}
|
||||
|
||||
private result_changed(result?: Result<unknown>): void {
|
||||
if (result) {
|
||||
this.content.val = create_result_body(result);
|
||||
|
||||
if (!result.success) {
|
||||
this.title.val = "Error";
|
||||
this.description.val = this.error_message.val;
|
||||
} else if (result.problems.length) {
|
||||
this.title.val = "Problems";
|
||||
this.description.val = this.problems_message.val;
|
||||
}
|
||||
} else {
|
||||
this.content.val = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function create_result_body(result: Result<unknown>): HTMLElement {
|
||||
const body = ul(...result.problems.map(problem => li(problem.ui_message)));
|
||||
body.style.cursor = "text";
|
||||
return body;
|
||||
}
|
@ -8,10 +8,11 @@ import { LogManager } from "../Logger";
|
||||
const logger = LogManager.get("core/gui/Widget");
|
||||
|
||||
export type WidgetOptions = {
|
||||
id?: string;
|
||||
class?: string;
|
||||
enabled?: boolean | Property<boolean>;
|
||||
tooltip?: string | Property<string>;
|
||||
readonly id?: string;
|
||||
readonly class?: string;
|
||||
readonly visible?: boolean | Property<boolean>;
|
||||
readonly enabled?: boolean | Property<boolean>;
|
||||
readonly tooltip?: string | Property<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -146,6 +147,12 @@ export abstract class Widget implements Disposable {
|
||||
this.element.classList.add(this.options.class);
|
||||
}
|
||||
|
||||
if (typeof this.options.visible === "boolean") {
|
||||
this.visible.val = this.options.visible;
|
||||
} else if (this.options.visible) {
|
||||
this.visible.bind_to(this.options.visible);
|
||||
}
|
||||
|
||||
if (typeof this.options.enabled === "boolean") {
|
||||
this.enabled.val = this.options.enabled;
|
||||
} else if (this.options.enabled) {
|
||||
|
@ -2,7 +2,7 @@ import { GuiStore, GuiTool } from "../../core/stores/GuiStore";
|
||||
import { AreaStore } from "../stores/AreaStore";
|
||||
import { QuestEditorStore } from "../stores/QuestEditorStore";
|
||||
import { AreaModel } from "../model/AreaModel";
|
||||
import { list_property, map } from "../../core/observable";
|
||||
import { list_property, map, property } from "../../core/observable";
|
||||
import { Property } from "../../core/observable/property/Property";
|
||||
import { undo_manager } from "../../core/undo/UndoManager";
|
||||
import { Controller } from "../../core/controllers/Controller";
|
||||
@ -26,7 +26,8 @@ const logger = LogManager.get("quest_editor/controllers/QuestEditorToolBarContro
|
||||
export type AreaAndLabel = { readonly area: AreaModel; readonly label: string };
|
||||
|
||||
export class QuestEditorToolBarController extends Controller {
|
||||
private quest_filename?: string;
|
||||
private _save_as_dialog_visible = property(false);
|
||||
private _filename = property("");
|
||||
|
||||
readonly vm_feature_active: boolean;
|
||||
readonly areas: Property<readonly AreaAndLabel[]>;
|
||||
@ -38,6 +39,8 @@ export class QuestEditorToolBarController extends Controller {
|
||||
readonly can_debug: Property<boolean>;
|
||||
readonly can_step: Property<boolean>;
|
||||
readonly can_stop: Property<boolean>;
|
||||
readonly save_as_dialog_visible: Property<boolean> = this._save_as_dialog_visible;
|
||||
readonly filename: Property<string> = this._filename;
|
||||
|
||||
constructor(
|
||||
gui_store: GuiStore,
|
||||
@ -97,16 +100,14 @@ export class QuestEditorToolBarController extends Controller {
|
||||
this.can_stop = quest_editor_store.quest_runner.running;
|
||||
|
||||
this.disposables(
|
||||
quest_editor_store.current_quest.observe(() => {
|
||||
this.quest_filename = undefined;
|
||||
}),
|
||||
quest_editor_store.current_quest.observe(() => this.set_filename("")),
|
||||
|
||||
gui_store.on_global_keydown(GuiTool.QuestEditor, "Ctrl-O", async () => {
|
||||
const files = await open_files({ accept: ".bin, .dat, .qst", multiple: true });
|
||||
this.parse_files(files);
|
||||
}),
|
||||
|
||||
gui_store.on_global_keydown(GuiTool.QuestEditor, "Ctrl-Shift-S", this.save_as),
|
||||
gui_store.on_global_keydown(GuiTool.QuestEditor, "Ctrl-Shift-S", this.save_as_clicked),
|
||||
|
||||
gui_store.on_global_keydown(GuiTool.QuestEditor, "Ctrl-Z", () => {
|
||||
undo_manager.undo();
|
||||
@ -145,7 +146,7 @@ export class QuestEditorToolBarController extends Controller {
|
||||
if (qst) {
|
||||
const buffer = await read_file(qst);
|
||||
quest = parse_qst_to_quest(new ArrayBufferCursor(buffer, Endianness.Little));
|
||||
this.quest_filename = qst.name;
|
||||
this.set_filename(basename(qst.name));
|
||||
|
||||
if (!quest) {
|
||||
logger.error("Couldn't parse quest file.");
|
||||
@ -161,7 +162,7 @@ export class QuestEditorToolBarController extends Controller {
|
||||
new ArrayBufferCursor(bin_buffer, Endianness.Little),
|
||||
new ArrayBufferCursor(dat_buffer, Endianness.Little),
|
||||
);
|
||||
this.quest_filename = bin.name || dat.name;
|
||||
this.set_filename(basename(bin.name || dat.name));
|
||||
|
||||
if (!quest) {
|
||||
logger.error("Couldn't parse quest file.");
|
||||
@ -181,28 +182,40 @@ export class QuestEditorToolBarController extends Controller {
|
||||
this.quest_editor_store.set_current_area(area);
|
||||
};
|
||||
|
||||
save_as_clicked = (): void => {
|
||||
if (this.quest_editor_store.current_quest.val) {
|
||||
this._save_as_dialog_visible.val = true;
|
||||
}
|
||||
};
|
||||
|
||||
save_as = (): void => {
|
||||
const quest = this.quest_editor_store.current_quest.val;
|
||||
if (!quest) return;
|
||||
|
||||
const default_file_name = this.quest_filename && basename(this.quest_filename);
|
||||
let filename = this.filename.val;
|
||||
const buffer = write_quest_qst(convert_quest_from_model(quest), filename);
|
||||
|
||||
let file_name = prompt("File name:", default_file_name);
|
||||
if (!file_name) return;
|
||||
|
||||
const buffer = write_quest_qst(convert_quest_from_model(quest), file_name);
|
||||
|
||||
if (!file_name.endsWith(".qst")) {
|
||||
file_name += ".qst";
|
||||
if (!filename.endsWith(".qst")) {
|
||||
filename += ".qst";
|
||||
}
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(new Blob([buffer], { type: "application/octet-stream" }));
|
||||
a.download = file_name;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
document.body.removeChild(a);
|
||||
|
||||
this.dismiss_save_as_dialog();
|
||||
};
|
||||
|
||||
dismiss_save_as_dialog = (): void => {
|
||||
this._save_as_dialog_visible.val = false;
|
||||
};
|
||||
|
||||
set_filename = (filename: string): void => {
|
||||
this._filename.val = filename;
|
||||
};
|
||||
|
||||
debug = (): void => {
|
||||
|
5
src/quest_editor/gui/QuestEditorToolBarView.css
Normal file
5
src/quest_editor/gui/QuestEditorToolBarView.css
Normal file
@ -0,0 +1,5 @@
|
||||
.quest_editor_QuestEditorToolBarView_save_as_dialog_content {
|
||||
display: grid;
|
||||
grid-template-columns: 100px max-content;
|
||||
align-items: center;
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import { QuestEditorToolBarController } from "../controllers/QuestEditorToolBarController";
|
||||
import { QuestEditorToolBar } from "./QuestEditorToolBar";
|
||||
import { QuestEditorToolBarView } from "./QuestEditorToolBarView";
|
||||
import { GuiStore } from "../../core/stores/GuiStore";
|
||||
import { create_area_store } from "../../../test/src/quest_editor/stores/store_creation";
|
||||
import { QuestEditorStore } from "../stores/QuestEditorStore";
|
||||
@ -11,7 +11,7 @@ test("Renders correctly.", () =>
|
||||
const area_store = create_area_store(disposer);
|
||||
const quest_editor_store = disposer.add(new QuestEditorStore(gui_store, area_store));
|
||||
const tool_bar = disposer.add(
|
||||
new QuestEditorToolBar(
|
||||
new QuestEditorToolBarView(
|
||||
disposer.add(
|
||||
new QuestEditorToolBarController(gui_store, area_store, quest_editor_store),
|
||||
),
|
@ -3,16 +3,32 @@ import { FileButton } from "../../core/gui/FileButton";
|
||||
import { Button } from "../../core/gui/Button";
|
||||
import { undo_manager } from "../../core/undo/UndoManager";
|
||||
import { Select } from "../../core/gui/Select";
|
||||
import { Icon } from "../../core/gui/dom";
|
||||
import { div, Icon } from "../../core/gui/dom";
|
||||
import { DropDown } from "../../core/gui/DropDown";
|
||||
import { Episode } from "../../core/data_formats/parsing/quest/Episode";
|
||||
import {
|
||||
AreaAndLabel,
|
||||
QuestEditorToolBarController,
|
||||
} from "../controllers/QuestEditorToolBarController";
|
||||
import { View } from "../../core/gui/View";
|
||||
import { Dialog } from "../../core/gui/Dialog";
|
||||
import { TextInput } from "../../core/gui/TextInput";
|
||||
import "./QuestEditorToolBarView.css";
|
||||
|
||||
export class QuestEditorToolBarView extends View {
|
||||
private readonly toolbar: ToolBar;
|
||||
|
||||
get element(): HTMLElement {
|
||||
return this.toolbar.element;
|
||||
}
|
||||
|
||||
get height(): number {
|
||||
return this.toolbar.height;
|
||||
}
|
||||
|
||||
export class QuestEditorToolBar extends ToolBar {
|
||||
constructor(ctrl: QuestEditorToolBarController) {
|
||||
super();
|
||||
|
||||
const new_quest_button = new DropDown({
|
||||
text: "New quest",
|
||||
icon_left: Icon.NewFile,
|
||||
@ -104,16 +120,47 @@ export class QuestEditorToolBar extends ToolBar {
|
||||
);
|
||||
}
|
||||
|
||||
super(...children);
|
||||
this.toolbar = this.disposable(new ToolBar(...children));
|
||||
|
||||
// "Save As" dialog.
|
||||
const filename_input = this.disposable(new TextInput("", { label: "File name:" }));
|
||||
const save_button = this.disposable(new Button({ text: "Save" }));
|
||||
const cancel_button = this.disposable(new Button({ text: "Cancel" }));
|
||||
|
||||
const save_as_dialog = this.disposable(
|
||||
new Dialog({
|
||||
title: "Save As",
|
||||
visible: ctrl.save_as_dialog_visible,
|
||||
content: div(
|
||||
{ className: "quest_editor_QuestEditorToolBarView_save_as_dialog_content" },
|
||||
filename_input.label!.element,
|
||||
filename_input.element,
|
||||
),
|
||||
footer: [save_button.element, cancel_button.element],
|
||||
}),
|
||||
);
|
||||
|
||||
save_as_dialog.element.addEventListener("keydown", evt => {
|
||||
if (evt.key === "Enter") {
|
||||
ctrl.save_as();
|
||||
}
|
||||
});
|
||||
|
||||
this.disposables(
|
||||
new_quest_button.chosen.observe(({ value: episode }) => ctrl.create_new_quest(episode)),
|
||||
|
||||
open_file_button.files.observe(({ value: files }) => ctrl.parse_files(files)),
|
||||
|
||||
save_as_button.onclick.observe(ctrl.save_as),
|
||||
save_as_button.onclick.observe(ctrl.save_as_clicked),
|
||||
save_as_button.enabled.bind_to(ctrl.can_save),
|
||||
|
||||
save_as_dialog.ondismiss.observe(ctrl.dismiss_save_as_dialog),
|
||||
|
||||
filename_input.value.observe(({ value }) => ctrl.set_filename(value)),
|
||||
|
||||
save_button.onclick.observe(ctrl.save_as),
|
||||
cancel_button.onclick.observe(ctrl.dismiss_save_as_dialog),
|
||||
|
||||
undo_button.onclick.observe(() => undo_manager.undo()),
|
||||
undo_button.enabled.bind_to(ctrl.can_undo),
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { QuestEditorToolBar } from "./QuestEditorToolBar";
|
||||
import { QuestEditorToolBarView } from "./QuestEditorToolBarView";
|
||||
import GoldenLayout, { Container, ContentItem, ItemConfigType } from "golden-layout";
|
||||
import { QuestInfoView } from "./QuestInfoView";
|
||||
import "golden-layout/src/css/goldenlayout-base.css";
|
||||
@ -63,7 +63,7 @@ export class QuestEditorView extends ResizableView {
|
||||
private readonly gui_store: GuiStore,
|
||||
quest_editor_store: QuestEditorStore,
|
||||
private readonly quest_editor_ui_persister: QuestEditorUiPersister,
|
||||
private readonly tool_bar: QuestEditorToolBar,
|
||||
private readonly tool_bar: QuestEditorToolBarView,
|
||||
create_quest_info_view: () => QuestInfoView,
|
||||
create_npc_counts_view: () => NpcCountsView,
|
||||
create_editor_renderer_view: () => QuestEditorRendererView,
|
||||
|
@ -9,7 +9,7 @@ import { EntityImageRenderer } from "./rendering/EntityImageRenderer";
|
||||
import { EntityAssetLoader } from "./loading/EntityAssetLoader";
|
||||
import { DisposableThreeRenderer } from "../core/rendering/Renderer";
|
||||
import { QuestEditorUiPersister } from "./persistence/QuestEditorUiPersister";
|
||||
import { QuestEditorToolBar } from "./gui/QuestEditorToolBar";
|
||||
import { QuestEditorToolBarView } from "./gui/QuestEditorToolBarView";
|
||||
import { QuestEditorToolBarController } from "./controllers/QuestEditorToolBarController";
|
||||
import { QuestInfoView } from "./gui/QuestInfoView";
|
||||
import { NpcCountsView } from "./gui/NpcCountsView";
|
||||
@ -59,7 +59,7 @@ export function initialize_quest_editor(
|
||||
quest_editor_store,
|
||||
quest_editor_ui_persister,
|
||||
disposer.add(
|
||||
new QuestEditorToolBar(
|
||||
new QuestEditorToolBarView(
|
||||
disposer.add(
|
||||
new QuestEditorToolBarController(gui_store, area_store, quest_editor_store),
|
||||
),
|
||||
|
@ -7,20 +7,32 @@ import { Endianness } from "../../core/data_formats/Endianness";
|
||||
import { parse_afs } from "../../core/data_formats/parsing/afs";
|
||||
import { LogManager } from "../../core/Logger";
|
||||
import { WritableListProperty } from "../../core/observable/property/list/WritableListProperty";
|
||||
import { list_property } from "../../core/observable";
|
||||
import { list_property, property } from "../../core/observable";
|
||||
import { ListProperty } from "../../core/observable/property/list/ListProperty";
|
||||
import { prs_decompress } from "../../core/data_formats/compression/prs/decompress";
|
||||
import { failure, Result, result_builder } from "../../core/Result";
|
||||
import { Severity } from "../../core/Severity";
|
||||
import { Property } from "../../core/observable/property/Property";
|
||||
import { WritableProperty } from "../../core/observable/property/WritableProperty";
|
||||
|
||||
const logger = LogManager.get("viewer/controllers/TextureController");
|
||||
|
||||
export class TextureController extends Controller {
|
||||
private readonly _textures: WritableListProperty<XvrTexture> = list_property();
|
||||
readonly textures: ListProperty<XvrTexture> = this._textures;
|
||||
private readonly _result_dialog_visible = property(false);
|
||||
private readonly _result: WritableProperty<Result<unknown> | undefined> = property(undefined);
|
||||
private readonly _result_problems_message = property("");
|
||||
private readonly _result_error_message = property("");
|
||||
|
||||
load_file = async (file: File): Promise<Result<unknown>> => {
|
||||
let result: Result<unknown>;
|
||||
readonly textures: ListProperty<XvrTexture> = this._textures;
|
||||
readonly result_dialog_visible: Property<boolean> = this._result_dialog_visible;
|
||||
readonly result: Property<Result<unknown> | undefined> = this._result;
|
||||
readonly result_problems_message: Property<string> = this._result_problems_message;
|
||||
readonly result_error_message: Property<string> = this._result_error_message;
|
||||
|
||||
load_file = async (file: File): Promise<void> => {
|
||||
this._result_problems_message.val = `Encountered some problems while opening "${file.name}".`;
|
||||
this._result_error_message.val = `Couldn't open "${file.name}".`;
|
||||
|
||||
try {
|
||||
const ext = filename_extension(file.name).toLowerCase();
|
||||
@ -28,7 +40,8 @@ export class TextureController extends Controller {
|
||||
const cursor = new ArrayBufferCursor(buffer, Endianness.Little);
|
||||
|
||||
if (ext === "xvm") {
|
||||
const xvm_result = (result = parse_xvm(cursor));
|
||||
const xvm_result = parse_xvm(cursor);
|
||||
this.set_result(xvm_result);
|
||||
|
||||
if (xvm_result.success) {
|
||||
this._textures.val = xvm_result.value.textures;
|
||||
@ -39,7 +52,7 @@ export class TextureController extends Controller {
|
||||
rb.add_result(afs_result);
|
||||
|
||||
if (!afs_result.success) {
|
||||
result = rb.failure();
|
||||
this.set_result(rb.failure());
|
||||
} else {
|
||||
const textures: XvrTexture[] = afs_result.value.flatMap(file => {
|
||||
const cursor = new ArrayBufferCursor(file, Endianness.Little);
|
||||
@ -56,24 +69,34 @@ export class TextureController extends Controller {
|
||||
});
|
||||
|
||||
if (textures.length) {
|
||||
result = rb.success(textures);
|
||||
this.set_result(rb.success(textures));
|
||||
} else {
|
||||
result = rb.failure();
|
||||
this.set_result(rb.failure());
|
||||
}
|
||||
|
||||
this._textures.val = textures;
|
||||
}
|
||||
} else {
|
||||
logger.debug(`Unsupported file extension in filename "${file.name}".`);
|
||||
result = failure([
|
||||
{ severity: Severity.Error, ui_message: "Unsupported file type." },
|
||||
]);
|
||||
this.set_result(
|
||||
failure([{ severity: Severity.Error, ui_message: "Unsupported file type." }]),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("Couldn't read file.", e);
|
||||
result = failure();
|
||||
this.set_result(failure());
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
dismiss_result_dialog = (): void => {
|
||||
this._result_dialog_visible.val = false;
|
||||
};
|
||||
|
||||
private set_result(result: Result<unknown>): void {
|
||||
this._result.val = result;
|
||||
|
||||
if (result.problems.length) {
|
||||
this._result_dialog_visible.val = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -12,10 +12,17 @@ import { LogManager } from "../../../core/Logger";
|
||||
import { prs_decompress } from "../../../core/data_formats/compression/prs/decompress";
|
||||
import { failure, Result, result_builder, success } from "../../../core/Result";
|
||||
import { Severity } from "../../../core/Severity";
|
||||
import { property } from "../../../core/observable";
|
||||
import { WritableProperty } from "../../../core/observable/property/WritableProperty";
|
||||
|
||||
const logger = LogManager.get("viewer/controllers/model/ModelToolBarController");
|
||||
|
||||
export class ModelToolBarController extends Controller {
|
||||
private readonly _result_dialog_visible = property(false);
|
||||
private readonly _result: WritableProperty<Result<unknown> | undefined> = property(undefined);
|
||||
private readonly _result_problems_message = property("");
|
||||
private readonly _result_error_message = property("");
|
||||
|
||||
readonly show_skeleton: Property<boolean>;
|
||||
readonly animation_frame_count: Property<number>;
|
||||
readonly animation_frame_count_label: Property<string>;
|
||||
@ -24,6 +31,11 @@ export class ModelToolBarController extends Controller {
|
||||
readonly animation_frame_rate: Property<number>;
|
||||
readonly animation_frame: Property<number>;
|
||||
|
||||
readonly result_dialog_visible: Property<boolean> = this._result_dialog_visible;
|
||||
readonly result: Property<Result<unknown> | undefined> = this._result;
|
||||
readonly result_problems_message: Property<string> = this._result_problems_message;
|
||||
readonly result_error_message: Property<string> = this._result_error_message;
|
||||
|
||||
constructor(private readonly store: ModelStore) {
|
||||
super();
|
||||
|
||||
@ -52,21 +64,24 @@ export class ModelToolBarController extends Controller {
|
||||
this.store.set_animation_frame(frame);
|
||||
};
|
||||
|
||||
load_file = async (file: File): Promise<Result<unknown>> => {
|
||||
let result: Result<unknown>;
|
||||
load_file = async (file: File): Promise<void> => {
|
||||
this._result_problems_message.val = `Encountered some problems while opening "${file.name}".`;
|
||||
this._result_error_message.val = `Couldn't open "${file.name}".`;
|
||||
|
||||
try {
|
||||
const buffer = await read_file(file);
|
||||
const cursor = new ArrayBufferCursor(buffer, Endianness.Little);
|
||||
|
||||
if (file.name.endsWith(".nj")) {
|
||||
const nj_result = (result = parse_nj(cursor));
|
||||
const nj_result = parse_nj(cursor);
|
||||
this.set_result(nj_result);
|
||||
|
||||
if (nj_result.success) {
|
||||
this.store.set_current_nj_object(nj_result.value[0]);
|
||||
}
|
||||
} else if (file.name.endsWith(".xj")) {
|
||||
const xj_result = (result = parse_xj(cursor));
|
||||
const xj_result = parse_xj(cursor);
|
||||
this.set_result(xj_result);
|
||||
|
||||
if (xj_result.success) {
|
||||
this.store.set_current_nj_object(xj_result.value[0]);
|
||||
@ -80,14 +95,15 @@ export class ModelToolBarController extends Controller {
|
||||
if (nj_object) {
|
||||
this.set_animation_playing(true);
|
||||
this.store.set_current_nj_motion(parse_njm(cursor, nj_object.bone_count()));
|
||||
result = success(undefined);
|
||||
this.set_result(success(undefined));
|
||||
} else {
|
||||
result = failure([
|
||||
{ severity: Severity.Error, ui_message: "No model to animate" },
|
||||
]);
|
||||
this.set_result(
|
||||
failure([{ severity: Severity.Error, ui_message: "No model to animate" }]),
|
||||
);
|
||||
}
|
||||
} else if (file.name.endsWith(".xvm")) {
|
||||
const xvm_result = (result = parse_xvm(cursor));
|
||||
const xvm_result = parse_xvm(cursor);
|
||||
this.set_result(xvm_result);
|
||||
|
||||
if (xvm_result.success) {
|
||||
this.store.set_current_textures(xvm_result.value.textures);
|
||||
@ -100,7 +116,7 @@ export class ModelToolBarController extends Controller {
|
||||
rb.add_result(afs_result);
|
||||
|
||||
if (!afs_result.success) {
|
||||
result = rb.failure();
|
||||
this.set_result(rb.failure());
|
||||
} else {
|
||||
const textures: XvrTexture[] = afs_result.value.flatMap(file => {
|
||||
const cursor = new ArrayBufferCursor(file, Endianness.Little);
|
||||
@ -117,24 +133,34 @@ export class ModelToolBarController extends Controller {
|
||||
});
|
||||
|
||||
if (textures.length) {
|
||||
result = rb.success(textures);
|
||||
this.set_result(rb.success(textures));
|
||||
} else {
|
||||
result = rb.failure();
|
||||
this.set_result(rb.failure());
|
||||
}
|
||||
|
||||
this.store.set_current_textures(textures);
|
||||
}
|
||||
} else {
|
||||
logger.debug(`Unsupported file extension in filename "${file.name}".`);
|
||||
result = failure([
|
||||
{ severity: Severity.Error, ui_message: "Unsupported file type." },
|
||||
]);
|
||||
this.set_result(
|
||||
failure([{ severity: Severity.Error, ui_message: "Unsupported file type." }]),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("Couldn't read file.", e);
|
||||
result = failure();
|
||||
this.set_result(failure());
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
dismiss_result_dialog = (): void => {
|
||||
this._result_dialog_visible.val = false;
|
||||
};
|
||||
|
||||
private set_result(result: Result<unknown>): void {
|
||||
this._result.val = result;
|
||||
|
||||
if (result.problems.length) {
|
||||
this._result_dialog_visible.val = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import { RendererWidget } from "../../core/gui/RendererWidget";
|
||||
import { TextureRenderer } from "../rendering/TextureRenderer";
|
||||
import { ResizableView } from "../../core/gui/ResizableView";
|
||||
import { TextureController } from "../controllers/TextureController";
|
||||
import { Dialog, show_result_in_dialog } from "../../core/gui/Dialog";
|
||||
import { ResultDialog } from "../../core/gui/ResultDialog";
|
||||
|
||||
export class TextureView extends ResizableView {
|
||||
readonly element = div({ className: "viewer_TextureView" });
|
||||
@ -27,22 +27,23 @@ export class TextureView extends ResizableView {
|
||||
|
||||
this.element.append(this.tool_bar.element, this.renderer_view.element);
|
||||
|
||||
const dialog = this.disposable(new Dialog());
|
||||
const dialog = this.disposable(
|
||||
new ResultDialog({
|
||||
visible: ctrl.result_dialog_visible,
|
||||
result: ctrl.result,
|
||||
problems_message: ctrl.result_problems_message,
|
||||
error_message: ctrl.result_error_message,
|
||||
}),
|
||||
);
|
||||
|
||||
this.disposables(
|
||||
this.open_file_button.files.observe(async ({ value: files }) => {
|
||||
this.open_file_button.files.observe(({ value: files }) => {
|
||||
if (files.length) {
|
||||
const file = files[0];
|
||||
const result = await ctrl.load_file(file);
|
||||
|
||||
show_result_in_dialog(
|
||||
dialog,
|
||||
result,
|
||||
`Encountered some problems while opening "${file.name}".`,
|
||||
`Couldn't open "${file.name}".`,
|
||||
);
|
||||
ctrl.load_file(files[0]);
|
||||
}
|
||||
}),
|
||||
|
||||
dialog.ondismiss.observe(ctrl.dismiss_result_dialog),
|
||||
);
|
||||
|
||||
this.finalize_construction();
|
||||
|
@ -7,7 +7,7 @@ import { Label } from "../../../core/gui/Label";
|
||||
import { Icon } from "../../../core/gui/dom";
|
||||
import { View } from "../../../core/gui/View";
|
||||
import { ModelToolBarController } from "../../controllers/model/ModelToolBarController";
|
||||
import { Dialog, show_result_in_dialog } from "../../../core/gui/Dialog";
|
||||
import { ResultDialog } from "../../../core/gui/ResultDialog";
|
||||
|
||||
export class ModelToolBarView extends View {
|
||||
private readonly toolbar: ToolBar;
|
||||
@ -55,24 +55,26 @@ export class ModelToolBarView extends View {
|
||||
),
|
||||
);
|
||||
|
||||
const dialog = this.disposable(new Dialog());
|
||||
const dialog = this.disposable(
|
||||
new ResultDialog({
|
||||
visible: ctrl.result_dialog_visible,
|
||||
result: ctrl.result,
|
||||
problems_message: ctrl.result_problems_message,
|
||||
error_message: ctrl.result_error_message,
|
||||
}),
|
||||
);
|
||||
|
||||
// Always-enabled controls.
|
||||
this.disposables(
|
||||
open_file_button.files.observe(async ({ value: files }) => {
|
||||
open_file_button.files.observe(({ value: files }) => {
|
||||
if (files.length) {
|
||||
const file = files[0];
|
||||
const result = await ctrl.load_file(file);
|
||||
show_result_in_dialog(
|
||||
dialog,
|
||||
result,
|
||||
`Encountered some problems while opening "${file.name}".`,
|
||||
`Couldn't open "${file.name}".`,
|
||||
);
|
||||
ctrl.load_file(files[0]);
|
||||
}
|
||||
}),
|
||||
|
||||
skeleton_checkbox.checked.observe(({ value }) => ctrl.set_show_skeleton(value)),
|
||||
|
||||
dialog.ondismiss.observe(ctrl.dismiss_result_dialog),
|
||||
);
|
||||
|
||||
// Controls that are only enabled when an animation is selected.
|
||||
|
Loading…
Reference in New Issue
Block a user