control/entityEditor/ProjectorEditorView.tstypescript
import { Checkbox } from "../../ui/Checkbox";
import { Color } from "../../ui/Color";
import { Input } from "../../ui/Input";
import { TitleBarView } from "../../ui/TitleBarView";
import { View, type IViewParams } from "../../ui/View";
import { htmlColorToRgba } from "../../utils/shared";
import {
    PROJECTOR_RENDER_MODE_COLOR,
    PROJECTOR_RENDER_MODE_LIGHT,
    type Projector
} from "../../renderer/projectors/Projector";

interface IProjectorEditorViewParams extends IViewParams {
    projector: Projector;
}

const TEMPLATE = `<div class="og-editor-panel"></div>`;

const MODE_TEMPLATE = `<div class="og-select">
    <div class="og-input-label">Render mode</div>
    <select>
        <option value="color">Color</option>
        <option value="light">Light</option>
    </select>
</div>`;

function getNumber(value: string): number | null {
    const parsed = parseFloat(value);
    return Number.isFinite(parsed) ? parsed : null;
}

export class ProjectorEditorView extends View<Projector> {
    protected _enabledView: Checkbox;
    protected _colorView: Color;
    protected _opacityView: Input;
    protected _priorityView: Input;
    protected _modeEl: HTMLSelectElement | null;
    protected _modeRowEl: HTMLElement | null;
    protected _titleBarView: TitleBarView;
    protected _bodyEl: HTMLElement | null;

    constructor(params: IProjectorEditorViewParams) {
        super({
            template: TEMPLATE,
            model: params.projector
        });

        this._enabledView = new Checkbox({
            label: "Enabled"
        });

        this._colorView = new Color({
            label: "Color"
        });

        this._opacityView = new Input({
            label: "Opacity",
            type: "number",
            min: 0,
            max: 1,
            step: 0.01,
            maxFixed: 4
        });

        this._priorityView = new Input({
            label: "Priority",
            type: "number",
            step: 1,
            maxFixed: 0
        });

        this._modeEl = null;
        this._modeRowEl = null;
        this._titleBarView = new TitleBarView({
            title: "Projector"
        });
        this._bodyEl = null;
    }

    public bindProjector(projector: Projector): void {
        this.model = projector;
    }

    public override render(params?: unknown): this {
        super.render(params);

        this._titleBarView.appendTo(this.el!);
        this._titleBarView.events.on("change", this._onTitleBarChange);

        this._bodyEl = document.createElement("div");
        this._bodyEl.classList.add("og-editor-panel__body");
        this.el!.appendChild(this._bodyEl);

        this._enabledView.appendTo(this._bodyEl);
        this._colorView.appendTo(this._bodyEl);
        this._opacityView.appendTo(this._bodyEl);
        this._priorityView.appendTo(this._bodyEl);

        this._modeRowEl = View.parseHTML(MODE_TEMPLATE)[0];
        this._modeEl = this._modeRowEl.querySelector("select");
        this._bodyEl.appendChild(this._modeRowEl);

        this._enabledView.events.on("change", this._onChangeEnabled);
        this._colorView.events.on("input", this._onChangeColor);
        this._opacityView.events.on("change", this._onChangeOpacity);
        this._priorityView.events.on("change", this._onChangePriority);
        this._modeEl!.addEventListener("change", this._onChangeMode);

        this.refresh();

        return this;
    }

    public refresh(): void {
        const projector = this.model;

        if (this._enabledView.checked !== projector.enabled) {
            this._enabledView.stopPropagation();
            this._enabledView.checked = projector.enabled;
        }

        const color = projector.getColor();
        if (this._colorView.value !== color) {
            this._colorView.stopPropagation();
            this._colorView.value = color;
        }

        this._opacityView.stopPropagation();
        this._opacityView.value = projector.getOpacity();

        this._priorityView.stopPropagation();
        this._priorityView.value = projector.priority;

        if (this._modeEl) {
            this._modeEl.value = projector.renderMode === PROJECTOR_RENDER_MODE_LIGHT ? "light" : "color";
        }
    }

    public override remove(): void {
        this._enabledView.remove();
        this._colorView.remove();
        this._opacityView.remove();
        this._priorityView.remove();

        if (this._modeEl) {
            this._modeEl.removeEventListener("change", this._onChangeMode);
        }
        if (this._modeRowEl && this._modeRowEl.parentNode) {
            this._modeRowEl.parentNode.removeChild(this._modeRowEl);
        }
        this._titleBarView.events.off("change", this._onTitleBarChange);
        this._titleBarView.remove();
        this._bodyEl = null;

        super.remove();
    }

    protected _onTitleBarChange = (isCollapsed: boolean): void => {
        this._bodyEl?.classList.toggle("og-editor-panel__body_collapsed", isCollapsed);
    };

    protected _onChangeEnabled = (enabled: boolean): void => {
        this.model.enabled = enabled;
    };

    protected _onChangeColor = (color: string): void => {
        const opacity = this.model.getOpacity();
        const rgba = htmlColorToRgba(color);
        this.model.setColor(rgba.x, rgba.y, rgba.z, opacity);
    };

    protected _onChangeOpacity = (value: string): void => {
        const opacity = getNumber(value);
        if (opacity !== null) {
            this.model.setOpacity(Math.max(0, Math.min(1, opacity)));
        }
    };

    protected _onChangePriority = (value: string): void => {
        const priority = getNumber(value);
        if (priority !== null) {
            this.model.priority = priority;
        }
    };

    protected _onChangeMode = (): void => {
        if (!this._modeEl) return;

        this.model.renderMode =
            this._modeEl.value === "light" ? PROJECTOR_RENDER_MODE_LIGHT : PROJECTOR_RENDER_MODE_COLOR;
    };
}