control/entityTree/EntityTree.tstypescript
import { Control } from "../Control";
import type { IControlParams } from "../Control";
import { Dialog } from "../../ui/Dialog";
import { Entity } from "../../entity/Entity";
import { ToggleButton } from "../../ui/ToggleButton";
import { View } from "../../ui/View";
import { EntityTreeView } from "./EntityTreeView";
import { EntityEditor } from "../entityEditor/EntityEditor";

export interface IEntityTreeParams extends IControlParams {
    entities?: Entity[];
    title?: string;
    top?: number;
    right?: number;
    width?: number;
    maxHeight?: number;
    expandedByDefault?: boolean;
    entityEditor?: EntityEditor;
}

const ICON_BUTTON_SVG = `<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
<path d="m120 0h300v300h-300z"/>
<path d="m1080 660v-300h-300v120h-480v-120h-60v840h60v-240h480v120h300v-300h-300v120h-480v-360h480v120z"/>
</svg>`;

const TEMPLATE = `<div class="og-entityTreeControl">
      <div class="og-entityTreeControl__tree"></div>
    </div>`;

export class EntityTree extends Control {
    protected _dialog: Dialog<null>;
    protected _toggleBtn: ToggleButton;
    protected _panel: View<null>;
    protected _treeView: EntityTreeView | null;

    protected _entities: Entity[];
    protected _expandedEntityIds: Set<number>;
    protected _expandedByDefault: boolean;
    protected _$tree: HTMLElement | null;
    protected _entityEditor: EntityEditor | null;

    constructor(options: IEntityTreeParams = {}) {
        super({
            name: "EntityTree",
            ...options
        });

        this._entities = options.entities ? [...options.entities] : [];
        this._expandedEntityIds = new Set<number>();
        this._expandedByDefault = options.expandedByDefault ?? false;
        this._entityEditor = options.entityEditor || null;

        this._dialog = new Dialog({
            title: options.title || "Entity Tree",
            top: options.top ?? 15,
            right: options.right ?? 67,
            visible: false,
            useHide: true,
            width: options.width ?? 340,
            maxHeight: options.maxHeight ?? 560
        });

        this._panel = new View({
            template: TEMPLATE
        });

        this._toggleBtn = new ToggleButton({
            classList: ["og-map-button", "og-entityTreeControl_button"],
            icon: ICON_BUTTON_SVG,
            title: "Entity tree"
        });

        this._treeView = null;
        this._$tree = null;
    }

    public override oninit() {
        if (!this.renderer) return;

        this._toggleBtn.appendTo(this.renderer.topRightContainer());
        this._dialog.appendTo(this.renderer.getUIContainer());

        if (!this._dialog.container) return;

        this._panel.appendTo(this._dialog.container);

        if (!this._panel.el) return;

        this._$tree = this._panel.el.querySelector(".og-entityTreeControl__tree");

        this._dialog.events.on("visibility", this._onDialogVisibility);
        this._toggleBtn.events.on("change", this._onToggleButtonChange);

        this._resolveEntityEditor();
        this._renderEntityTree();
    }

    public override onremove() {
        this._dialog.events.off("visibility", this._onDialogVisibility);
        this._toggleBtn.events.off("change", this._onToggleButtonChange);

        this._clearTreeView();

        this._dialog.remove();
        this._panel.remove();
        this._toggleBtn.remove();
    }

    public override onactivate() {}

    public override ondeactivate() {
        this._dialog.hide();
    }

    public setEntity(entity: Entity | null): void {
        this._entities = entity ? [entity] : [];
        this._expandedEntityIds.clear();
        this._renderEntityTree();
    }

    public setEntities(entities: Entity[]): void {
        this._entities = [...entities];
        this._expandedEntityIds.clear();
        this._renderEntityTree();
    }

    public addEntity(entity: Entity): void {
        const rootEntity = entity.rootEntity;

        if (!this._containsEntity(this._entities, rootEntity)) {
            this._entities.push(rootEntity);
        }

        let current: Entity | null = entity;
        while (current) {
            this._expandedEntityIds.add(current.id);
            current = current.parent;
        }

        this._renderEntityTree();
    }

    public removeEntity(entity: Entity): void {
        if (!this._entities.length) return;

        const removedIds = new Set<number>();
        this._collectEntityIds(entity, removedIds);

        const removeRecursively = (entities: Entity[]): boolean => {
            for (let i = 0; i < entities.length; i++) {
                const current = entities[i];

                if (current.isEqual(entity)) {
                    entities.splice(i, 1);
                    return true;
                }

                if (removeRecursively(current.childEntities)) {
                    return true;
                }
            }

            return false;
        };

        removeRecursively(this._entities);
        removedIds.forEach((id) => this._expandedEntityIds.delete(id));
        this._renderEntityTree();
    }

    protected _containsEntity(entities: Entity[], target: Entity): boolean {
        for (let i = 0; i < entities.length; i++) {
            if (entities[i].isEqual(target)) {
                return true;
            }
        }
        return false;
    }

    protected _collectEntityIds(entity: Entity, result: Set<number>): void {
        result.add(entity.id);
        for (let i = 0; i < entity.childEntities.length; i++) {
            this._collectEntityIds(entity.childEntities[i], result);
        }
    }

    protected _onDialogVisibility = (isVisible: boolean): void => {
        this._toggleBtn.setActive(isVisible);
        if (isVisible) {
            this._dialog.positionNearElementOnFirstOpen(this._toggleBtn.el, this.renderer?.div || undefined);
        }
    };

    protected _onToggleButtonChange = (isActive: boolean): void => {
        this._dialog.setVisibility(isActive);
    };

    protected _onEntitySelect = (entity: Entity): void => {
        this._resolveEntityEditor();
        if (this._entityEditor) {
            this._entityEditor.selectEntity(entity);
            this._entityEditor.positionDialogLeftOf(this._dialog);
        }
    };

    protected _resolveEntityEditor(): void {
        if (this._entityEditor || !this.renderer) return;

        const control = this.renderer.controls.EntityEditor;
        if (control instanceof EntityEditor) {
            this._entityEditor = control;
        }
    }

    protected _clearTreeView(): void {
        if (this._treeView) {
            this._treeView.events.off("select", this._onEntitySelect);
            this._treeView.remove();
            this._treeView = null;
        }
    }

    protected _renderEntityTree(): void {
        if (!this._$tree) return;

        this._clearTreeView();
        this._$tree.innerHTML = "";

        if (!this._entities.length) {
            const emptyEl = document.createElement("div");
            emptyEl.classList.add("og-entityTreeControl__empty");
            emptyEl.textContent = "No entities";
            this._$tree.appendChild(emptyEl);
            return;
        }

        this._treeView = new EntityTreeView({
            model: this._entities,
            expandedByDefault: this._expandedByDefault,
            expandedEntityIds: this._expandedEntityIds
        });
        this._treeView.appendTo(this._$tree);
        this._treeView.events.on("select", this._onEntitySelect);
    }
}