// Contains classes: AlingActionType, ArrangeActionType, ChangeType, DragType, EnabledDependsOn, LongActionType,
//     MessageType, ToolbarButton, Change, UpdateDto, LongAction, QueueEmptyFlags,
//     HistoryChange, HistoryPoint, MimicHistory, MimicClipboard
// Depends on mimic-model.js

// Specifies the action types for component alignment.
// Readable values are used in data-* attributes.
class AlingActionType {
    static ALIGN_LEFTS = "align-lefts";
    static ALIGN_CENTERS = "align-centers";
    static ALIGN_RIGHTS = "align-rights";
    static ALIGN_TOPS = "align-tops";
    static ALIGN_MIDDLES = "align-middles";
    static ALIGN_BOTTOMS = "align-bottoms";
    static SAME_WIDTH = "same-width";
    static SAME_HEIGHT = "same-height";
    static SAME_SIZE = "same-size";
    static HOR_SPACING = "hor-spacing";
    static VERT_SPACING = "vert-spacing";

    static sameParentRequired(actionType) {
        return actionType === AlingActionType.ALIGN_LEFTS ||
            actionType === AlingActionType.ALIGN_CENTERS ||
            actionType === AlingActionType.ALIGN_RIGHTS ||
            actionType === AlingActionType.ALIGN_TOPS ||
            actionType === AlingActionType.ALIGN_MIDDLES ||
            actionType === AlingActionType.ALIGN_BOTTOMS ||
            actionType === AlingActionType.HOR_SPACING ||
            actionType === AlingActionType.VERT_SPACING;
    }
}

// Specifies the action types for component arrangement.
// Readable values are used in data-* attributes.
class ArrangeActionType {
    static SEND_TO_BACK = "send-to-back";
    static SEND_BACKWARD = "send-backward";
    static BRING_FORWARD = "bring-forward";
    static BRING_TO_FRONT = "bring-to-front";
    static PLACE_BEFORE = "place-before";
    static PLACE_AFTER = "place-after";
    static SELECT_PARENT = "select-parent";

    static longActionRequired(actionType) {
        return actionType === ArrangeActionType.PLACE_BEFORE ||
            actionType === ArrangeActionType.PLACE_AFTER ||
            actionType === ArrangeActionType.SELECT_PARENT;
    }
}

// Specifies the change types.
// Readable values are sent in HTTP requests.
class ChangeType {
    // Dependencies
    static ADD_DEPENDENCY = "AddDependency";
    static REMOVE_DEPENDENCY = "RemoveDependency";

    // Document
    static UPDATE_DOCUMENT = "UpdateDocument";

    // Components
    static ADD_COMPONENT = "AddComponent";
    static UPDATE_COMPONENT = "UpdateComponent";
    static UPDATE_PARENT = "UpdateParent";
    static ARRANGE_COMPONENT = "ArrangeComponent";
    static REMOVE_COMPONENT = "RemoveComponent";

    // Images
    static ADD_IMAGE = "AddImage";
    static REMOVE_IMAGE = "RemoveImage";
}

// Specifies the drag types.
class DragType {
    static NONE = 0;
    static MOVE = 1;
    static RESIZE_LEFT = 2;
    static RESIZE_RIGHT = 3;
    static RESIZE_TOP = 4;
    static RESIZE_BOT = 5;
    static RESIZE_TOP_LEFT = 6;
    static RESIZE_TOP_RIGHT = 7;
    static RESIZE_BOT_LEFT = 8;
    static RESIZE_BOT_RIGHT = 9;

    static getCursor(dragType) {
        if (dragType) {
            switch (dragType) {
                case DragType.MOVE:
                    return "move";

                case DragType.RESIZE_LEFT:
                case DragType.RESIZE_RIGHT:
                    return "ew-resize";

                case DragType.RESIZE_TOP:
                case DragType.RESIZE_BOT:
                    return "ns-resize";

                case DragType.RESIZE_TOP_LEFT:
                case DragType.RESIZE_BOT_RIGHT:
                    return "nwse-resize";

                case DragType.RESIZE_TOP_RIGHT:
                case DragType.RESIZE_BOT_LEFT:
                    return "nesw-resize";
            }
        }

        return "";
    }

    static isResizeLeft(dragType) {
        return dragType === DragType.RESIZE_LEFT ||
            dragType === DragType.RESIZE_TOP_LEFT ||
            dragType === DragType.RESIZE_BOT_LEFT;
    }

    static isResizeRight(dragType) {
        return dragType === DragType.RESIZE_RIGHT ||
            dragType === DragType.RESIZE_TOP_RIGHT ||
            dragType === DragType.RESIZE_BOT_RIGHT;
    }

    static isResizeTop(dragType) {
        return dragType === DragType.RESIZE_TOP ||
            dragType === DragType.RESIZE_TOP_LEFT ||
            dragType === DragType.RESIZE_TOP_RIGHT;
    }

    static isResizeBot(dragType) {
        return dragType === DragType.RESIZE_BOT ||
            dragType === DragType.RESIZE_BOT_LEFT ||
            dragType === DragType.RESIZE_BOT_RIGHT;
    }
}

// Specifies the categories on which the availability of the toolbar buttons depends.
class EnabledDependsOn {
    static NOT_SPECIFIED = 0;
    static SELECTION = 1;
    static HISTORY = 2;
}

// Specifies the long action types.
class LongActionType {
    static NONE = 0;
    static ADD = 1;
    static PASTE = 2;
    static DRAG = 3;
    static ARRANGE = 4;

    static isPointing(actionType) {
        return actionType === LongActionType.ADD ||
            actionType === LongActionType.PASTE ||
            actionType === LongActionType.ARRANGE;
    }
}

// Specifies the message types for toasts.
class MessageType {
    static INFO = 0;
    static SUCCESS = 1;
    static WARNING = 2;
    static ERROR = 3;
}

// Specifies the toolbar button selectors.
class ToolbarButton {
    static RELOAD = "#btnReload";
    static SAVE = "#btnSave";
    static UNDO = "#btnUndo";
    static REDO = "#btnRedo";
    static CUT = "#btnCut";
    static COPY = "#btnCopy";
    static PASTE = "#btnPaste";
    static REMOVE = "#btnRemove";
    static POINTER = "#btnPointer";
    static ALIGN = "button.rs-btn-align";
    static ARRANGE = "button.rs-btn-arrange";
}

// Represents a change in a mimic for data transfer.
class Change {
    static MAX_SHIFT = 1000000;

    changeType = "";
    objectID = 0;
    objectIDs = null;
    objectName = "";
    properties = null;
    parentID = 0;
    shift = 0;
    siblingID = 0;
    indexes = null;

    constructor(changeType) {
        this.changeType = changeType;
    }

    _setObjectID(objectID) {
        if (Array.isArray(objectID)) {
            if (objectID.length === 1) {
                this.objectID = objectID[0];
            } else {
                this.objectIDs = objectID;
            }
        } else {
            this.objectID = objectID;
        }
    }

    getObjectIDs() {
        return this.objectID > 0
            ? [this.objectID]
            : (this.objectIDs ?? []);
    }

    setProperty(name, value) {
        this.properties ??= {};
        this.properties[name] = value;
        return this;
    }

    static addDependency(dependency) {
        let change = new Change(ChangeType.ADD_DEPENDENCY);
        change.objectName = dependency.typeName;
        change.properties = Object.assign({}, dependency);
        return change;
    }

    static removeDependency(typeName) {
        let change = new Change(ChangeType.REMOVE_DEPENDENCY);
        change.objectName = typeName;
        return change;
    }

    static updateDocument(opt_properties) {
        let change = new Change(ChangeType.UPDATE_DOCUMENT);

        if (opt_properties) {
            change.properties = Object.assign({}, opt_properties);
        }

        return change;
    }

    static addComponent(component) {
        let change = new Change(ChangeType.ADD_COMPONENT);
        change.objectID = component.id;
        change.properties = Object.assign({}, component.properties);
        change.properties.name = component.name;
        change.properties.typeName = component.typeName;
        change.parentID = component.parentID;
        change.index = component.index;
        return change;
    }

    static updateComponent(componentID, opt_properties) {
        let change = new Change(ChangeType.UPDATE_COMPONENT);
        change._setObjectID(componentID);

        if (opt_properties) {
            change.properties = Object.assign({}, opt_properties);
        }

        return change;
    }

    static updateLocation(component) {
        return Change.updateComponent(component.id, {
            location: component.properties.location
        });
    }

    static updateSize(component) {
        return Change.updateComponent(component.id, {
            size: component.properties.size
        });
    }

    static updateParent(component) {
        let change = new Change(ChangeType.UPDATE_PARENT);
        change.objectID = component.id;
        change.properties = {
            location: component.properties.location
        };
        change.parentID = component.parentID;
        change.index = component.index;
        return change;
    }

    static arrangeComponent(parentID, componentID, shift, opt_siblingID) {
        let change = new Change(ChangeType.ARRANGE_COMPONENT);
        change._setObjectID(componentID);
        change.parentID = parentID;
        change.shift = shift;
        change.siblingID = opt_siblingID;
        return change;
    }

    static arrangeByIndexes(parentID, componentIDs, indexes) {
        let change = new Change(ChangeType.ARRANGE_COMPONENT);
        change.objectIDs = componentIDs;
        change.parentID = parentID;
        change.indexes = indexes;
        return change;
    }

    static removeComponent(componentID) {
        let change = new Change(ChangeType.REMOVE_COMPONENT);
        change._setObjectID(componentID);
        return change;
    }

    static addImage(image) {
        let change = new Change(ChangeType.ADD_IMAGE);
        change.objectName = image.name;
        change.properties = Object.assign({}, image);
        return change;
    }

    static removeImage(imageName) {
        let change = new Change(ChangeType.REMOVE_IMAGE);
        change.objectName = imageName;
        return change;
    }
}

// Represents a data transfer object containing mimic changes.
class UpdateDto {
    mimicKey;
    changes;
    json;

    constructor(mimicKey, opt_changes) {
        this.mimicKey = mimicKey;
        this.changes = opt_changes ?? [];
        this.json = JSON.stringify({
            mimicKey: this.mimicKey,
            changes: this.changes
        });
    }
}

// Represents a continuous user action based on multiple mouse events.
class LongAction {
    actionType;        // action type
    componentTypeName; // component to add
    dragType;          // drag type: move or resize
    startPoint;        // start point of drag
    moved;             // components were moved during drag operation
    resized;           // components were resized during drag operation
    arrangeType;       // arrange type: before, after or parent

    constructor(actionType) {
        this.actionType = actionType ?? LongActionType.NONE;
    }

    getCursor() {
        if (this.actionType === LongActionType.ADD ||
            this.actionType === LongActionType.PASTE) {
            return "crosshair";
        } else if (this.actionType === LongActionType.DRAG) {
            return DragType.getCursor(this.dragType);
        } else if (this.actionType === LongActionType.ARRANGE) {
            return "pointer";
        } else {
            return "";
        }
    }

    static add(componentTypeName) {
        let action = new LongAction(LongActionType.ADD);
        action.componentTypeName = componentTypeName;
        return action;
    }

    static paste() {
        return new LongAction(LongActionType.PASTE);
    }

    static drag(dragType, startPoint) {
        let action = new LongAction(LongActionType.DRAG);
        action.dragType = dragType;
        action.startPoint = startPoint;
        action.moved = false;
        action.resized = false;
        return action;
    }

    static arrange(arrangeType) {
        let action = new LongAction(LongActionType.ARRANGE);
        action.arrangeType = arrangeType;
        return action;
    }
}

// Represents flags to process when the update queue is empty.
class QueueEmptyFlags {
    saveRequired = false;
    reloadRequired = false;
    fullReloadRequired = false;
}

// Represents a change in history.
class HistoryChange {
    changeType = "";
    objectID = null;
    oldObjectJson = null;
    newObjectJson = null;
    oldIndex = null;
    newIndex = null;

    constructor(source) {
        Object.assign(this, source);
    }

    getOldObject() {
        return this.oldObjectJson ? JSON.parse(this.oldObjectJson) : null;
    }

    getNewObject() {
        return this.newObjectJson ? JSON.parse(this.newObjectJson) : null;
    }
}

// Represents a single point in history.
class HistoryPoint {
    changes; // instances of HistoryChange class

    constructor(changes) {
        this.changes = changes ?? [];
    }

    toReversed() {
        let reversedChanges = [];

        for (let change of this.changes.toReversed()) {
            switch (change.changeType) {
                case ChangeType.UPDATE_DOCUMENT:
                    reversedChanges.push(new HistoryChange({
                        changeType: ChangeType.UPDATE_DOCUMENT,
                        oldObjectJson: change.newObjectJson,
                        newObjectJson: change.oldObjectJson
                    }));
                    break;

                case ChangeType.ADD_COMPONENT:
                    reversedChanges.push(new HistoryChange({
                        changeType: ChangeType.REMOVE_COMPONENT,
                        objectID: change.objectID,
                        oldObjectJson: change.newObjectJson
                    }));
                    break;

                case ChangeType.UPDATE_COMPONENT:
                    reversedChanges.push(new HistoryChange({
                        changeType: ChangeType.UPDATE_COMPONENT,
                        objectID: change.objectID,
                        oldObjectJson: change.newObjectJson,
                        newObjectJson: change.oldObjectJson
                    }));
                    break;

                case ChangeType.REMOVE_COMPONENT:
                    reversedChanges.push(new HistoryChange({
                        changeType: ChangeType.ADD_COMPONENT,
                        objectID: change.objectID,
                        newObjectJson: change.oldObjectJson
                    }));
                    break;

                case ChangeType.UPDATE_PARENT:
                    reversedChanges.push(new HistoryChange({
                        changeType: ChangeType.UPDATE_PARENT,
                        objectID: change.objectID,
                        oldObjectJson: change.newObjectJson,
                        newObjectJson: change.oldObjectJson
                    }));
                    break;

                case ChangeType.ARRANGE_COMPONENT:
                    reversedChanges.push(new HistoryChange({
                        changeType: ChangeType.ARRANGE_COMPONENT,
                        objectID: change.objectID,
                        oldIndex: change.newIndex,
                        newIndex: change.oldIndex
                    }));
                    break;
            }
        }

        return reversedChanges.length > 0
            ? new HistoryPoint(reversedChanges)
            : null;
    }
}

// Contains the history of mimic changes.
class MimicHistory {
    static MAX_SIZE = 20; // maximum number of history points

    _points;           // history points
    _headIndex;        // index to add new points
    _documentJson;     // chached mimic document as a JSON string
    _componentJsonMap; // cached components as JSON strings accessible by ID

    constructor() {
        this.clear();
    }

    get canUndo() {
        return this._headIndex > 0;
    }

    get canRedo() {
        return this._headIndex < this._points.length;
    }

    _createHistoryChanges(mimicChange, mimic) {
        let historyChanges = [];

        switch (mimicChange.changeType) {
            case ChangeType.UPDATE_DOCUMENT: {
                let oldDocumentJson = this._documentJson;
                let newDocumentJson = JSON.stringify(mimic.document);
                this._documentJson = newDocumentJson;

                historyChanges.push(new HistoryChange({
                    changeType: ChangeType.UPDATE_DOCUMENT,
                    oldObjectJson: oldDocumentJson,
                    newObjectJson: newDocumentJson
                }));

                break;
            }
            case ChangeType.ADD_COMPONENT: {
                let componentID = mimicChange.objectID;
                let componentJson = this._getComponentJsonFromMimic(componentID, mimic);
                this._updateComponentCache(componentID, componentJson);

                historyChanges.push(new HistoryChange({
                    changeType: ChangeType.ADD_COMPONENT,
                    objectID: componentID,
                    newObjectJson: componentJson
                }));

                break;
            }
            case ChangeType.UPDATE_COMPONENT: {
                for (let componentID of mimicChange.getObjectIDs()) {
                    let oldComponentJson = this._getComponentJsonFromCache(componentID);
                    let newComponentJson = this._getComponentJsonFromMimic(componentID, mimic);
                    this._updateComponentCache(componentID, newComponentJson);

                    historyChanges.push(new HistoryChange({
                        changeType: ChangeType.UPDATE_COMPONENT,
                        objectID: componentID,
                        oldObjectJson: oldComponentJson,
                        newObjectJson: newComponentJson
                    }));
                }

                break;
            }
            case ChangeType.REMOVE_COMPONENT: {
                for (let componentID of mimicChange.getObjectIDs()) {
                    historyChanges.push(new HistoryChange({
                        changeType: ChangeType.REMOVE_COMPONENT,
                        objectID: componentID,
                        oldObjectJson: this._getComponentJsonFromCache(componentID)
                    }));
                    this._removeComponentFromCache(componentID);
                }

                break;
            }
            case ChangeType.UPDATE_PARENT: {
                let componentID = mimicChange.objectID;
                let oldComponentJson = this._getComponentJsonFromCache(componentID);
                let newComponentJson = this._getComponentJsonFromMimic(componentID, mimic);
                this._updateComponentCache(componentID, newComponentJson);

                historyChanges.push(new HistoryChange({
                    changeType: ChangeType.UPDATE_PARENT,
                    objectID: componentID,
                    oldObjectJson: oldComponentJson,
                    newObjectJson: newComponentJson
                }));

                break;
            }
            case ChangeType.ARRANGE_COMPONENT: {
                for (let componentID of mimicChange.getObjectIDs()) {
                    let oldComponentJson = this._getComponentJsonFromCache(componentID);
                    let newComponent = mimic.componentMap.get(componentID);
                    this._updateComponentCache(componentID, newComponent);

                    if (oldComponentJson && newComponent) {
                        let oldComponent = JSON.parse(oldComponentJson);
                        historyChanges.push(new HistoryChange({
                            changeType: ChangeType.ARRANGE_COMPONENT,
                            objectID: componentID,
                            oldIndex: oldComponent.index,
                            newIndex: newComponent.index
                        }));
                    }
                }

                break;
            }
        }

        return historyChanges;
    }

    _getComponentJsonFromCache(componentID) {
        return this._componentJsonMap.get(componentID);
    }

    _getComponentJsonFromMimic(componentID, mimic) {
        let component = mimic.componentMap.get(componentID);
        return component ? JSON.stringify(component.toPlainObject()) : null;
    }

    _updateComponentCache(componentID, component) {
        if (component instanceof rs.mimic.Component) {
            this._componentJsonMap.set(componentID, JSON.stringify(component.toPlainObject()));
        } else if (typeof component === "string" && component) {
            this._componentJsonMap.set(componentID, component);
        } else {
            this._componentJsonMap.delete(componentID);
        }
    }

    _removeComponentFromCache(componentID) {
        this._componentJsonMap.delete(componentID);
    }

    clear() {
        this._points = [];
        this._headIndex = 0;
        this._documentJson = null;
        this._componentJsonMap = new Map();
    }

    rememberDocument(mimic, overwriteExisting) {
        if (overwriteExisting || !this._documentJson) {
            this._documentJson = JSON.stringify(mimic.document);
        }
    }

    rememberComponent(component, overwriteExisting) {
        if (overwriteExisting || !this._componentJsonMap.has(component.id)) {
            this._updateComponentCache(component.id, component);
        }
    }

    addPoint(mimic, mimicChanges) {
        // create history changes
        let historyChanges = [];

        for (let mimicChange of mimicChanges) {
            if (mimicChange instanceof Change) {
                historyChanges.push(...this._createHistoryChanges(mimicChange, mimic));
            }
        }

        if (historyChanges.length > 0) {
            // remove history points after head index
            if (this._headIndex < this._points.length) {
                this._points.length = this._headIndex;
            }

            // add history point
            let point = new HistoryPoint(historyChanges);
            this._points.push(point);

            // remove history points if history size exceeded
            let removeCount = this._points.length - MimicHistory.MAX_SIZE;

            if (removeCount > 0) {
                this._points.splice(0, removeCount);
            }

            // update head index
            this._headIndex = this._points.length;
        }
    }

    getUndoPoint() {
        if (this._headIndex > 0) {
            this._headIndex--;
            let point = this._points[this._headIndex];
            return point.toReversed();
        } else {
            return null;
        }
    }

    getRedoPoint() {
        if (this._headIndex < this._points.length) {
            let point = this._points[this._headIndex];
            this._headIndex++;
            return point;
        } else {
            return null;
        }
    }
}

// Represents a clipboard for copying and pasting components.
class MimicClipboard {
    static MARKER = "MimicEditor";

    _isEmpty;
    _clipboardData;
    _componentJsons;
    _rootID;
    _offset;

    constructor() {
        this._clear();
    }

    get rootID() {
        return this._clipboardData ? this._clipboardData.rootID : this._rootID;
    }

    get offset() {
        return this._clipboardData ? this._clipboardData.offset : this._offset;
    }

    get isEmpty() {
        return this._isEmpty;
    }

    _clear() {
        this._isEmpty = true;
        this._clipboardData = null;
        this._componentJsons = [];
        this._rootID = 0;
        this._offset = { x: 0, y: 0 };
    }

    static _validate(clipboardData) {
        return clipboardData &&
            clipboardData.marker === MimicClipboard.MARKER &&
            Array.isArray(clipboardData.components) &&
            Number.isInteger(clipboardData.rootID) &&
            clipboardData.offset instanceof Object;
    }

    async defineEmptiness() {
        if (this._componentJsons.length > 0) {
            this._isEmpty = false;
        } else {
            try { this._isEmpty = !await navigator.clipboard.readText(); }
            catch { this._isEmpty = true; }
        }
    }

    async writeComponents(components) {
        // extract information from components
        let plainObjects = [];
        this._clear();

        if (Array.isArray(components) && components.length > 0) {
            this._isEmpty = false;
            this._rootID = components[0].parentID; // assuming that parents are the same
            this._offset = rs.mimic.MimicHelper.getMinLocation(components);

            for (let component of components) {
                plainObjects.push(component.toPlainObject())

                if (component.isContainer) {
                    plainObjects.push(...component.getAllChildren().map(c => c.toPlainObject()));
                }
            }

            this._componentJsons = plainObjects.map(o => JSON.stringify(o));
        }

        // write to system buffer
        try {
            await navigator.clipboard.writeText(JSON.stringify({
                marker: MimicClipboard.MARKER,
                components: plainObjects,
                rootID: this._rootID,
                offset: this._offset
            }));
        } catch (ex) {
            console.error("Error writing to clipboard: " + ex.message);
        }
    }

    async readComponents() {
        // read from system buffer first
        try {
            let text = await navigator.clipboard.readText();
            let data;
            try { data = JSON.parse(text); }
            catch { data = null; }

            if (MimicClipboard._validate(data)) {
                this._clipboardData = data;
                return data.components;
            }
        } catch (ex) {
            console.error("Error reading from clipboard: " + ex.message);
        }

        // return plain objects that are not instances of Component
        return this._componentJsons.map(j => JSON.parse(j));
    }
}

// Contains classes: ModalContext, ModalBase, ModalShowArgs, ColorModal, FaceplateModal,
//     FontModal, ImageEditModal, ImageSelectModal, PropertyModal, TextEditor
// Depends on jquery, bootstrap, mimic-model.js, prop-grid.js

// Represents a context of a modal dialog.
class ModalContext {
    oldValue = null;
    newValue = null;
    result = false;
    callback = null;

    constructor(source) {
        Object.assign(this, source);
    }
}

// A base class for modal dialogs.
class ModalBase {
    _elem;
    _modal;
    _context;

    constructor(elemID) {
        this._elem = $("#" + elemID);

        if (this._elem.length === 0) {
            throw new Error(`Modal #${elemID} not found.`);
        }

        this._modal = new bootstrap.Modal(this._elem[0]);
        this._context = new ModalContext();
        this._bindEvents();
    }

    _bindEvents() {
        this._elem.find("form:first").on("submit", (event) => {
            this._elem.find(".modal-footer .btn-primary:first").trigger("click");
            event.preventDefault();
        });

        this._elem
            .on("shown.bs.modal", () => {
                this._setFocus();
                this._handleShown();
            })
            .on("hidden.bs.modal", () => {
                this._invokeCallback();
            });
    }

    _setFocus() {
        // implement in derived classes
    }

    _handleShown() {
        // implement in derived classes
    }

    _invokeCallback() {
        if (this._context.result && this._context.callback instanceof Function) {
            this._context.callback.call(this, this._context);
        }
    }

    show(showArgs, callback) {
        // implement in derived classes
    }
}

// Represents arguments for the show method.
class ModalShowArgs {
    topObject = null;
    value = null;
    options = null;

    constructor(source) {
        Object.assign(this, source);
    }
}

// Represents a modal dialog for choosing a color.
class ColorModal extends ModalBase {
    static _RECENT_COLOR_COUNT = 5;

    _recentColors = [];
    _colorsFilled = false;

    _bindEvents() {
        super._bindEvents();

        $("#colorModal_btnOK").on("click", () => {
            let color = $("#colorModal_txtColor").val();
            this._addRecentColor(color);
            this._context.newValue = color;
            this._context.result = true;
            this._modal.hide();
        });

        $("#colorModal_divKnownColors table").on("click", "tr", (event) => {
            let rowElem = $(event.currentTarget);
            let color = rowElem.data("color");

            if (color?.name) {
                this._selectRow(rowElem);
                $("#colorModal_txtColor").val(color.name);
            }
        });
    }

    _setFocus() {
        $("#colorModal_txtColor").focus();
    }

    _handleShown() {
        let color = $("#colorModal_txtColor").val();
        this._selectColor(color);
    }

    _fillRecentColors() {
        this._fillColorTable("colorModal_tblRecentColors",
            this._recentColors.map(color => {
                return { name: color, hex: "" };
            }));
    }

    _fillNamedColors() {
        if (this._colorsFilled) {
            return;
        }

        // colors taken from https://htmlcolorcodes.com/color-names/

        const redColors = [
            { name: "IndianRed", hex: "#CD5C5C" },
            { name: "LightCoral", hex: "#F08080" },
            { name: "Salmon", hex: "#FA8072" },
            { name: "DarkSalmon", hex: "#E9967A" },
            { name: "LightSalmon", hex: "#FFA07A" },
            { name: "Crimson", hex: "#DC143C" },
            { name: "Red", hex: "#FF0000" },
            { name: "FireBrick", hex: "#B22222" },
            { name: "DarkRed", hex: "#8B0000" },
        ];

        const pinkColors = [
            { name: "Pink", hex: "#FFC0CB" },
            { name: "LightPink", hex: "#FFB6C1" },
            { name: "HotPink", hex: "#FF69B4" },
            { name: "DeepPink", hex: "#FF1493" },
            { name: "MediumVioletRed", hex: "#C71585" },
            { name: "PaleVioletRed", hex: "#DB7093" },
        ];

        const orangeColors = [
            { name: "LightSalmon", hex: "#FFA07A" },
            { name: "Coral", hex: "#FF7F50" },
            { name: "Tomato", hex: "#FF6347" },
            { name: "OrangeRed", hex: "#FF4500" },
            { name: "DarkOrange", hex: "#FF8C00" },
            { name: "Orange", hex: "#FFA500" },
        ];

        const yellowColors = [
            { name: "Gold", hex: "#FFD700" },
            { name: "Yellow", hex: "#FFFF00" },
            { name: "LightYellow", hex: "#FFFFE0" },
            { name: "LemonChiffon", hex: "#FFFACD" },
            { name: "LightGoldenrodYellow", hex: "#FAFAD2" },
            { name: "PapayaWhip", hex: "#FFEFD5" },
            { name: "Moccasin", hex: "#FFE4B5" },
            { name: "PeachPuff", hex: "#FFDAB9" },
            { name: "PaleGoldenrod", hex: "#EEE8AA" },
            { name: "Khaki", hex: "#F0E68C" },
            { name: "DarkKhaki", hex: "#BDB76B" },
        ];

        const purpleColors = [
            { name: "Lavender", hex: "#E6E6FA" },
            { name: "Thistle", hex: "#D8BFD8" },
            { name: "Plum", hex: "#DDA0DD" },
            { name: "Violet", hex: "#EE82EE" },
            { name: "Orchid", hex: "#DA70D6" },
            { name: "Fuchsia", hex: "#FF00FF" },
            { name: "Magenta", hex: "#FF00FF" },
            { name: "MediumOrchid", hex: "#BA55D3" },
            { name: "MediumPurple", hex: "#9370DB" },
            { name: "RebeccaPurple", hex: "#663399" },
            { name: "BlueViolet", hex: "#8A2BE2" },
            { name: "DarkViolet", hex: "#9400D3" },
            { name: "DarkOrchid", hex: "#9932CC" },
            { name: "DarkMagenta", hex: "#8B008B" },
            { name: "Purple", hex: "#800080" },
            { name: "Indigo", hex: "#4B0082" },
            { name: "SlateBlue", hex: "#6A5ACD" },
            { name: "DarkSlateBlue", hex: "#483D8B" },
            { name: "MediumSlateBlue", hex: "#7B68EE" },
        ];

        const greenColors = [
            { name: "GreenYellow", hex: "#ADFF2F" },
            { name: "Chartreuse", hex: "#7FFF00" },
            { name: "LawnGreen", hex: "#7CFC00" },
            { name: "Lime", hex: "#00FF00" },
            { name: "LimeGreen", hex: "#32CD32" },
            { name: "PaleGreen", hex: "#98FB98" },
            { name: "LightGreen", hex: "#90EE90" },
            { name: "MediumSpringGreen", hex: "#00FA9A" },
            { name: "SpringGreen", hex: "#00FF7F" },
            { name: "MediumSeaGreen", hex: "#3CB371" },
            { name: "SeaGreen", hex: "#2E8B57" },
            { name: "ForestGreen", hex: "#228B22" },
            { name: "Green", hex: "#008000" },
            { name: "DarkGreen", hex: "#006400" },
            { name: "YellowGreen", hex: "#9ACD32" },
            { name: "OliveDrab", hex: "#6B8E23" },
            { name: "Olive", hex: "#808000" },
            { name: "DarkOliveGreen", hex: "#556B2F" },
            { name: "MediumAquamarine", hex: "#66CDAA" },
            { name: "DarkSeaGreen", hex: "#8FBC8B" },
            { name: "LightSeaGreen", hex: "#20B2AA" },
            { name: "DarkCyan", hex: "#008B8B" },
            { name: "Teal", hex: "#008080" },
        ];

        const blueColors = [
            { name: "Aqua", hex: "#00FFFF" },
            { name: "Cyan", hex: "#00FFFF" },
            { name: "LightCyan", hex: "#E0FFFF" },
            { name: "PaleTurquoise", hex: "#AFEEEE" },
            { name: "Aquamarine", hex: "#7FFFD4" },
            { name: "Turquoise", hex: "#40E0D0" },
            { name: "MediumTurquoise", hex: "#48D1CC" },
            { name: "DarkTurquoise", hex: "#00CED1" },
            { name: "CadetBlue", hex: "#5F9EA0" },
            { name: "SteelBlue", hex: "#4682B4" },
            { name: "LightSteelBlue", hex: "#B0C4DE" },
            { name: "PowderBlue", hex: "#B0E0E6" },
            { name: "LightBlue", hex: "#ADD8E6" },
            { name: "SkyBlue", hex: "#87CEEB" },
            { name: "LightSkyBlue", hex: "#87CEFA" },
            { name: "DeepSkyBlue", hex: "#00BFFF" },
            { name: "DodgerBlue", hex: "#1E90FF" },
            { name: "CornflowerBlue", hex: "#6495ED" },
            { name: "MediumSlateBlue", hex: "#7B68EE" },
            { name: "RoyalBlue", hex: "#4169E1" },
            { name: "Blue", hex: "#0000FF" },
            { name: "MediumBlue", hex: "#0000CD" },
            { name: "DarkBlue", hex: "#00008B" },
            { name: "Navy", hex: "#000080" },
            { name: "MidnightBlue", hex: "#191970" },
        ];

        const brownColors = [
            { name: "Cornsilk", hex: "#FFF8DC" },
            { name: "BlanchedAlmond", hex: "#FFEBCD" },
            { name: "Bisque", hex: "#FFE4C4" },
            { name: "NavajoWhite", hex: "#FFDEAD" },
            { name: "Wheat", hex: "#F5DEB3" },
            { name: "BurlyWood", hex: "#DEB887" },
            { name: "Tan", hex: "#D2B48C" },
            { name: "RosyBrown", hex: "#BC8F8F" },
            { name: "SandyBrown", hex: "#F4A460" },
            { name: "Goldenrod", hex: "#DAA520" },
            { name: "DarkGoldenrod", hex: "#B8860B" },
            { name: "Peru", hex: "#CD853F" },
            { name: "Chocolate", hex: "#D2691E" },
            { name: "SaddleBrown", hex: "#8B4513" },
            { name: "Sienna", hex: "#A0522D" },
            { name: "Brown", hex: "#A52A2A" },
            { name: "Maroon", hex: "#800000" },
        ];

        const whiteColors = [
            { name: "White", hex: "#FFFFFF" },
            { name: "Snow", hex: "#FFFAFA" },
            { name: "HoneyDew", hex: "#F0FFF0" },
            { name: "MintCream", hex: "#F5FFFA" },
            { name: "Azure", hex: "#F0FFFF" },
            { name: "AliceBlue", hex: "#F0F8FF" },
            { name: "GhostWhite", hex: "#F8F8FF" },
            { name: "WhiteSmoke", hex: "#F5F5F5" },
            { name: "SeaShell", hex: "#FFF5EE" },
            { name: "Beige", hex: "#F5F5DC" },
            { name: "OldLace", hex: "#FDF5E6" },
            { name: "FloralWhite", hex: "#FFFAF0" },
            { name: "Ivory", hex: "#FFFFF0" },
            { name: "AntiqueWhite", hex: "#FAEBD7" },
            { name: "Linen", hex: "#FAF0E6" },
            { name: "LavenderBlush", hex: "#FFF0F5" },
            { name: "MistyRose", hex: "#FFE4E1" },
        ];

        const grayColors = [
            { name: "Gainsboro", hex: "#DCDCDC" },
            { name: "LightGray", hex: "#D3D3D3" },
            { name: "Silver", hex: "#C0C0C0" },
            { name: "DarkGray", hex: "#A9A9A9" },
            { name: "Gray", hex: "#808080" },
            { name: "DimGray", hex: "#696969" },
            { name: "LightSlateGray", hex: "#778899" },
            { name: "SlateGray", hex: "#708090" },
            { name: "DarkSlateGray", hex: "#2F4F4F" },
            { name: "Black", hex: "#000000" },
        ];

        this._fillColorTable("colorModal_tblRedColors", redColors);
        this._fillColorTable("colorModal_tblPinkColors", pinkColors);
        this._fillColorTable("colorModal_tblOrangeColors", orangeColors);
        this._fillColorTable("colorModal_tblYellowColors", yellowColors);
        this._fillColorTable("colorModal_tblPurpleColors", purpleColors);
        this._fillColorTable("colorModal_tblGreenColors", greenColors);
        this._fillColorTable("colorModal_tblBlueColors", blueColors);
        this._fillColorTable("colorModal_tblBrownColors", brownColors);
        this._fillColorTable("colorModal_tblWhiteColors", whiteColors);
        this._fillColorTable("colorModal_tblGrayColors", grayColors);
        this._colorsFilled = true;
    }

    _fillColorTable(tableID, colors) {
        let tableElem = $("#" + tableID);
        tableElem.prop("hidden", colors.length === 0);
        let tbodyElem = $("<tbody></tbody>");

        for (let color of colors) {
            let rowElem = $("<tr></tr>").data("color", color).appendTo(tbodyElem);
            let circleElem = $("<div class='rounded-circle'></div>").css("background-color", color.name);
            $("<td></td>").append(circleElem).appendTo(rowElem);
            $("<td></td>").text(color.name).appendTo(rowElem);
            $("<td></td>").text(color.hex).appendTo(rowElem);
        }

        tableElem.children("tbody").remove(); // remove table body if exists
        tableElem.append(tbodyElem);
    }

    _selectColor(color) {
        let rowToSelect = null;

        if (color) {
            $("#colorModal_divKnownColors table tr").each((index, element) => {
                let rowElem = $(element);
                let colorObj = rowElem.data("color");

                if (colorObj && (colorObj.name === color || colorObj.hex === color)) {
                    rowToSelect = rowElem;
                    return false; // break loop
                }
            });
        }

        this._selectRow(rowToSelect);

        if (rowToSelect) {
            rowToSelect[0].scrollIntoView(false);
        } else {
            $("#colorModal_divKnownColors").scrollTop(0);
        }
    }

    _selectRow(rowElem) {
        $("#colorModal_divKnownColors table tr").removeClass("rs-selected");
        rowElem?.addClass("rs-selected");
    }

    _addRecentColor(color) {
        if (color) {
            let newRecentColors = [color];
            let colorIndex = 0;

            while (colorIndex < this._recentColors.length && newRecentColors.length < ColorModal._RECENT_COLOR_COUNT) {
                let recentColor = this._recentColors[colorIndex++];

                if (recentColor !== color) {
                    newRecentColors.push(recentColor);
                }
            }

            this._recentColors = newRecentColors;
        }
    }

    show(showArgs, callback) {
        let color = showArgs.value;
        this._context = new ModalContext({
            oldValue: color,
            callback: callback
        });

        $("#colorModal_txtColor").val(color);
        this._fillRecentColors();
        this._fillNamedColors();
        this._modal.show();
    }
}

// Represents a modal dialog for editing a faceplate meta.
class FaceplateModal extends ModalBase {
    _bindEvents() {
        super._bindEvents();

        $("#faceplateModal_btnOK").on("click", () => {
            let formElem = $("#frmFaceplateModal");

            if (formElem[0].checkValidity()) {
                this._readFields(this._context.newValue);
                this._context.result = true;
                this._modal.hide();
            }

            formElem.addClass("was-validated");
        });
    }

    _setFocus() {
        $("#faceplateModal_txtTypeName").focus();
    }

    _showFields(faceplateMeta) {
        $("#frmFaceplateModal").removeClass("was-validated")
        $("#faceplateModal_txtTypeName").val(faceplateMeta.typeName);
        $("#faceplateModal_txtPath").val(faceplateMeta.path);
    }

    _readFields(faceplateMeta) {
        faceplateMeta.typeName = $("#faceplateModal_txtTypeName").val();
        faceplateMeta.path = $("#faceplateModal_txtPath").val();
    }

    show(showArgs, callback) {
        let faceplateMeta = showArgs.value;
        let newFaceplateMeta = new rs.mimic.FaceplateMeta();
        Object.assign(newFaceplateMeta, faceplateMeta); // faceplateMeta can be null

        this._context = new ModalContext({
            oldValue: faceplateMeta,
            newValue: newFaceplateMeta,
            callback: callback
        });

        this._showFields(newFaceplateMeta);
        this._modal.show();
    }
}

// Represents a modal dialog for editing a font.
class FontModal extends ModalBase {
    _bindEvents() {
        super._bindEvents();

        $("#fontModal_btnOK").on("click", () => {
            let formElem = $("#frmFontModal");

            if (formElem[0].checkValidity()) {
                this._readFields(this._context.newValue);
                this._context.result = true;
                this._modal.hide();
            }

            formElem.addClass("was-validated");
        });

        $("#fontModal_chkInherit").on("change", (event) => {
            let inherit = $(event.target).prop("checked");
            $("#fontModal_fsProps").prop("disabled", inherit);
        });
    }

    _setFocus() {
        $("#fontModal_chkInherit").focus();
    }

    _showFields(font) {
        $("#frmFontModal").removeClass("was-validated")
        $("#fontModal_chkInherit").prop("checked", font.inherit);
        $("#fontModal_fsProps").prop("disabled", font.inherit);
        $("#fontModal_txtName").val(font.name);
        $("#fontModal_txtSize").val(font.size);
        $("#fontModal_chkBold").prop("checked", font.bold);
        $("#fontModal_chkItalic").prop("checked", font.italic);
        $("#fontModal_chkUnderline").prop("checked", font.underline);
    }

    _readFields(font) {
        font.inherit = $("#fontModal_chkInherit").prop("checked");
        font.name = $("#fontModal_txtName").val();
        font.size = Number.parseInt($("#fontModal_txtSize").val()) || 0;
        font.bold = $("#fontModal_chkBold").prop("checked");
        font.italic = $("#fontModal_chkItalic").prop("checked");
        font.underline = $("#fontModal_chkUnderline").prop("checked");
    }

    show(showArgs, callback) {
        let font = showArgs.value;
        let newFont = new rs.mimic.Font(font);
        this._context = new ModalContext({
            oldValue: font,
            newValue: newFont,
            callback: callback
        });

        this._showFields(newFont);
        this._modal.show();
    }
}

// Represents a modal dialog for editing an image.
class ImageEditModal extends ModalBase {
    _bindEvents() {
        super._bindEvents();

        $("#imageEditModal_btnOK").on("click", () => {
            let formElem = $("#frmImageEditModal");

            if (formElem[0].checkValidity()) {
                this._readFields(this._context.newValue);
                this._context.result = true;
                this._modal.hide();
            }

            formElem.addClass("was-validated");
        });

        $("#imageEditModal_btnUpload").on("click", () => {
            $("#imageEditModal_file").trigger("click");
        });

        $("#imageEditModal_btnDownload").on("click", (event) => {
            let linkElem = $(event.target);
            this._downloadImage(linkElem);
        });

        $("#imageEditModal_file").on("change", (event) => {
            let file = event.target.files[0];

            if (file) {
                this._uploadImage(file);
            }
        });
    }

    _setFocus() {
        $("#imageEditModal_txtName").focus();
    }

    _showFields(image) {
        $("#frmImageEditModal").removeClass("was-validated")
        $("#imageEditModal_txtName").val(image.name);
        $("#imageEditModal_file").val("");
    }

    _readFields(image) {
        image.name = $("#imageEditModal_txtName").val();
        image.dataUrl = $("#imageEditModal_imgPreview").attr("src");
    }

    _showFileSize(size) {
        $("#imageEditModal_spnFileSize").text(size ? "(" + Math.round(size / 1024) + " KB)" : "");
    }

    _getFileSize(imageData) {
        return imageData ? atob(imageData).length : 0;
    }

    _showImage(dataUrl) {
        if (dataUrl) {
            $("#imageEditModal_imgPreview").attr("src", dataUrl).removeClass("d-none");
            $("#imageEditModal_divNoImage").addClass("d-none");
            $("#imageEditModal_btnDownload").prop("disabled", false);
        } else {
            $("#imageEditModal_imgPreview").attr("src", "").addClass("d-none");
            $("#imageEditModal_divNoImage").removeClass("d-none");
            $("#imageEditModal_btnDownload").prop("disabled", true);
        }
    }

    _uploadImage(file) {
        let reader = new FileReader();

        reader.onload = () => {
            let txtName = $("#imageEditModal_txtName");

            if (!txtName.val()) {
                txtName.val(file.name);
            }

            this._showFileSize(file.size);
            this._showImage(reader.result);
        };

        reader.onerror = () => {
            console.error("Error reading file.");
        };

        reader.readAsDataURL(file);
    }

    _downloadImage(linkElem) {
        let name = $("#imageEditModal_txtName").val();
        let dataUrl = $("#imageEditModal_imgPreview").attr("src");
        linkElem
            .attr("download", name)
            .attr("href", dataUrl);
    }

    show(showArgs, callback) {
        let image = showArgs.value;
        let newImage = new rs.mimic.Image();
        Object.assign(newImage, image); // image can be null

        this._context = new ModalContext({
            oldValue: image,
            newValue: newImage,
            callback: callback
        });

        this._showFields(newImage);
        this._showFileSize(this._getFileSize(newImage.data));
        this._showImage(newImage.dataUrl);
        this._modal.show();
    }
}

// Represents a modal dialog for choosing an image.
class ImageSelectModal extends ModalBase {
    static _RECENT_IMAGE_COUNT = 3;

    _mimic;
    _recentImages = [];
    _imagesFilled = false;
    _lazyObserver = null;

    constructor(elemID, mimic) {
        super(elemID);

        if (mimic == null) {
            throw new Error("Mimic must not be null.");
        }

        this._mimic = mimic;
        this._initLazyObserver();
    }

    _bindEvents() {
        super._bindEvents();

        $("#imageSelectModal_btnOK").on("click", () => {
            let imageName = $("#imageSelectModal_txtName").val();
            this._addRecentImage(imageName);
            this._context.newValue = imageName;
            this._context.result = true;
            this._modal.hide();
        });

        $("#imageSelectModal_divAvailableImages").on("click", ".rs-image-item", (event) => {
            let itemElem = $(event.currentTarget);
            let image = itemElem.data("image");

            if (image?.name) {
                this._selectItem(itemElem);
                $("#imageSelectModal_txtName").val(image.name);
            }
        });
    }

    _setFocus() {
        $("#imageSelectModal_txtName").focus();
    }

    _handleShown() {
        let imageName = $("#imageSelectModal_txtName").val();
        this._selectImage(imageName);
    }

    _initLazyObserver() {
        this._lazyObserver = new IntersectionObserver(this._handleIntersect, {
            root: $("#imageSelectModal_divAvailableImages")[0],
            rootMargin: "100px 0px", // double item height
            threshold: 0.1
        });
    }

    _handleIntersect(entries, observer) {
        entries.forEach(entry => {
            if (entry.isIntersecting) {
                let itemElem = $(entry.target);
                let imgElem = itemElem.find("img:first");
                let image = itemElem.data("image");

                if (imgElem.not("[src]") && image) {
                    imgElem.attr("src", image.dataUrl);
                }

                observer.unobserve(entry.target);
            }
        });
    }

    _fillRecentImages() {
        this._fillImageList("imageSelectModal_divRecentImages", this._recentImages);
    }

    _fillAllImages() {
        if (!this._imagesFilled) {
            this._fillImageList("imageSelectModal_divAllImages", this._mimic.images);
            this._imagesFilled = true;
        }
    }

    _fillImageList(listID, images) {
        let listElem = $("#" + listID);
        listElem.prop("hidden", images.length === 0);
        let contentElem = $("<div class='rs-image-list-content'></div>");

        for (let image of images) {
            let itemElem = $("<div class='rs-image-item'></div>").data("image", image).appendTo(contentElem);
            this._lazyObserver.observe(itemElem[0]);
            $("<div class='rs-image-thumb'><img decoding='async' /></div>").appendTo(itemElem);
            $("<div class='rs-image-name'></div>").text(image.name).appendTo(itemElem);
        }

        listElem.children(".rs-image-list-content").remove(); // remove list content if exists
        listElem.append(contentElem);
    }

    _selectImage(imageName) {
        let itemToSelect = null;

        if (imageName) {
            $("#imageSelectModal_divAvailableImages div.rs-image-item").each((index, element) => {
                let itemElem = $(element);
                let image = itemElem.data("image");

                if (image && image.name === imageName) {
                    itemToSelect = itemElem;
                    return false; // break loop
                }
            });
        }

        this._selectItem(itemToSelect);

        if (itemToSelect) {
            itemToSelect[0].scrollIntoView(false);
        } else {
            $("#imageSelectModal_divAvailableImages").scrollTop(0);
        }
    }

    _selectItem(itemElem) {
        $("#imageSelectModal_divAvailableImages div.rs-image-item").removeClass("rs-selected");
        itemElem?.addClass("rs-selected");
    }

    _addRecentImage(imageName) {
        let image = this._mimic.imageMap.get(imageName);

        if (image) {
            let newRecentImages = [image];
            let imageIndex = 0;

            while (imageIndex < this._recentImages.length &&
                newRecentImages.length < ImageSelectModal._RECENT_IMAGE_COUNT) {
                let recentImage = this._recentImages[imageIndex++];

                if (recentImage !== image) {
                    newRecentImages.push(recentImage);
                }
            }

            this._recentImages = newRecentImages;
        }
    }

    show(showArgs, callback) {
        let imageName = showArgs.value;
        this._context = new ModalContext({
            oldValue: imageName,
            callback: callback
        });

        $("#imageSelectModal_txtName").val(imageName);
        this._fillRecentImages();
        this._fillAllImages();
        this._modal.show();
    }

    invalidate() {
        this._imagesFilled = false;
    }
}

// Represents a modal dialog for choosing an object property.
class PropertyModal extends ModalBase {
    static DEFAULT_OPTIONS = {
        canSelectObject: false
    };

    _mimic;
    _options = null;

    constructor(elemID, mimic) {
        super(elemID);

        if (mimic == null) {
            throw new Error("Mimic must not be null.");
        }

        this._mimic = mimic;
    }

    _bindEvents() {
        super._bindEvents();

        $("#propertyModal_btnOK").on("click", () => {
            this._context.newValue = $("#propertyModal_txtPropertyName").val();
            this._context.result = true;
            this._modal.hide();
        });

        // show properties of the selected object
        $("#propertyModal_selObject").on("change", (event) => {
            let selectedVal = $(event.target).val();
            let selectedObj = this._findObjectById(selectedVal);
            this._showObjectProperties(selectedObj);
            this._selectProperty(null);
        });

        // select clicked property
        $("#propertyModal_divObjectProperties").on("click", "li", (event) => {
            event.stopPropagation();
            let itemElem = $(event.currentTarget);

            if (!itemElem.hasClass("rs-disabled")) {
                let propertyName = itemElem.attr("data-path");
                $("#propertyModal_txtPropertyName").val(propertyName);
                this._selectItem(itemElem);
            }
        });
    }

    _setFocus() {
        $("#propertyModal_txtPropertyName").focus();
    }

    _handleShown() {
        let propertyName = $("#propertyModal_txtPropertyName").val();
        this._selectProperty(propertyName);
    }

    _selectProperty(propertyName) {
        let itemToSelect = null;

        if (propertyName) {
            $("#propertyModal_divObjectProperties li.rs-item").each((index, element) => {
                let itemElem = $(element);
                let path = itemElem.attr("data-path");

                if (path === propertyName) {
                    itemToSelect = itemElem;
                    return false; // break loop
                }
            });
        }

        this._selectItem(itemToSelect);

        if (itemToSelect) {
            itemToSelect[0].scrollIntoView(false);
        } else {
            $("#propertyModal_divObjectProperties").scrollTop(0);
        }
    }

    _selectItem(itemElem) {
        $("#propertyModal_divObjectProperties li.rs-item").removeClass("rs-selected");
        itemElem?.addClass("rs-selected");
    }

    _showSingleObject(obj) {
        $("#propertyModal_txtObjectDisplayName").val(obj?.toString()).prop("hidden", false);
        $("#propertyModal_selObject").prop("hidden", true);
    }

    _showObjectProperties(obj) {
        let appendItemsFunc = (parentElem, obj, parentPath) => {
            let targetObject = PropGridHelper.getTargetObject(obj);
            let objectDescriptor = PropGridHelper.getObjectDescriptor(obj);

            if (targetObject) {
                for (let [name, value] of Object.entries(targetObject).sort(([a], [b]) => a.localeCompare(b))) {
                    let path = parentPath + name;
                    let propertyDescriptor = objectDescriptor?.get(name);
                    let isBindable = propertyDescriptor?.isBindable ?? true;
                    let itemText = propertyDescriptor ? `${name} (${propertyDescriptor.displayName})` : name;
                    let itemElem = $("<li class='rs-item'></li>").attr("data-path", path).appendTo(parentElem);
                    $("<span class='rs-item-text'></span>").text(itemText).appendTo(itemElem);

                    if (isBindable) {
                        if (value instanceof Object) {
                            let childListElem = $("<ul></ul>").appendTo(itemElem);
                            appendItemsFunc(childListElem, value, path + ".");
                        }
                    } else {
                        itemElem.addClass("rs-disabled");
                    }
                }
            }
        };

        let listElem = $("<ul></ul>");
        let rootPath = this._options.canSelectObject ? obj?.name + "." : "";
        appendItemsFunc(listElem, obj, rootPath);
        $("#propertyModal_divObjectProperties").empty().append(listElem);
    }

    _findObjectById(optionValue) {
        let componentID = Number.parseInt(optionValue);
        return Number.isFinite(componentID)
            ? (componentID > 0 ? this._mimic.componentMap.get(componentID) : this._mimic)
            : null;
    }

    _findObjectByProperty(propertyName) {
        let objectName = propertyName ? propertyName.split('.')[0] : null;

        if (objectName) {
            return objectName === this._mimic.name
                ? this._mimic
                : this._mimic.components.find(c => c.name === objectName);
        } else {
            return null;
        }
    }

    _fillObjectList(selectedObject) {
        // clear list
        let selectElem = $("#propertyModal_selObject");
        let firstOptionElem = selectElem.children("option:first");
        firstOptionElem.detach();
        selectElem.empty();

        // create list options
        let optionArr = [firstOptionElem];
        let objectArr = [this._mimic, ...this._mimic.components].sort((a, b) => a.id - b.id);

        for (let obj of objectArr) {
            optionArr.push($("<option></option>")
                .val(obj.id)
                .text(obj.toString())
                .prop("selected", selectedObject === obj)
            );
        }

        firstOptionElem.prop("selected", !selectedObject);
        selectElem.append(optionArr).prop("hidden", false);
        $("#propertyModal_txtObjectDisplayName").prop("hidden", true);
    }

    show(showArgs, callback) {
        let propertyName = showArgs.value;
        this._context = new ModalContext({
            oldValue: propertyName,
            callback: callback
        });

        $("#propertyModal_txtPropertyName").val(propertyName);
        this._options = showArgs.options ?? PropertyModal.DEFAULT_OPTIONS;

        if (this._options.canSelectObject) {
            let obj = this._findObjectByProperty(propertyName);
            this._fillObjectList(obj);
            this._showObjectProperties(obj);
        } else {
            this._showSingleObject(showArgs.topObject);
            this._showObjectProperties(showArgs.topObject);
        }

        this._modal.show();
    }
}

// Represents a modal dialog for editing text.
class TextEditor extends ModalBase {
    static DEFAULT_OPTIONS = {
        language: "none"
    };

    _flask;

    constructor(elemID) {
        super(elemID);
        let editorElem = $("#textEditor_divEditor");
        this._flask = new CodeFlask(editorElem[0], TextEditor.DEFAULT_OPTIONS);
    }

    _bindEvents() {
        super._bindEvents();

        $("#textEditor_btnOK").on("click", () => {
            this._context.newValue = this._flask.getCode();
            this._context.result = true;
            this._modal.hide();
        });
    }

    _setFocus() {
        $("#textEditor_divEditor textarea").focus();
    }

    _showLanguage(language) {
        let lblLanguage = $("#textEditor_lblLanguage");

        switch (language) {
            case "css":
                lblLanguage.text("CSS").removeClass("d-none");
                break;

            case "js":
                lblLanguage.text("JavaScript").removeClass("d-none");
                break;

            case "markup":
                lblLanguage.text("HTML/XML").removeClass("d-none");
                break;

            default:
                lblLanguage.text("Text").addClass("d-none");
                break;
        }
    }

    show(showArgs, callback) {
        let text = showArgs.value;
        this._context = new ModalContext({
            oldValue: text,
            callback: callback
        });

        let options = showArgs.options ?? TextEditor.DEFAULT_OPTIONS;
        this._showLanguage(options.language);
        this._flask.updateLanguage(options.language);
        this._flask.updateCode(text);
        this._modal.show();
    }
}

// Contains classes: PropGrid, PropGridEventType, PropGridHelper, PropGridDialogs,
//     ProxyObject, PointProxy, SizeProxy, UnionObject
// Depends on jquery, tweakpane, tweakpane-plugin-essentials, scada-common.js, mimic-model.js, mimic-descr.js

// Interacts with Tweakpane to provide property grid functionality.
class PropGrid {
    _tweakpaneElem;
    _tweakpane;
    _phrases;
    _eventSource = document.createElement("prop-grid");
    _selectedObject = null;
    _topObject = null;
    _topPropertyName = "";
    _parentStack = [];

    constructor(elemID, phrases) {
        this._tweakpaneElem = $("#" + elemID);
        this._tweakpane = new Tweakpane({
            container: this._tweakpaneElem[0]
        });
        this._tweakpane.registerPlugin(TweakpaneEssentialsPlugin);
        this._phrases = phrases ?? {};
        this._bindEvents();
    }

    _bindEvents() {
        this._tweakpaneElem.on("click", ".rs-array-item", (event) => {
            // select the clicked array item
            this._tweakpaneElem.find(".rs-array-item").removeClass("rs-selected");
            $(event.currentTarget).addClass("rs-selected");
        });
    }

    _selectObject(obj) {
        this._selectedObject = obj;
        this._parentStack = [];
        this._topObject = obj;
        this._topPropertyName = "";
        this._showObjectProperties(obj, false);
    }

    _selectChildObject(propertyName, obj) {
        let parent = this._selectedObject;

        if (parent === this._topObject) {
            this._topPropertyName = propertyName;
        }

        this._selectedObject = obj;
        this._parentStack.push(parent);
        this._showObjectProperties(obj, true);
    }

    _selectParentObject() {
        let parent = this._parentStack.pop();
        let isChild = this._parentStack.length > 0;

        if (!isChild) {
            this._topPropertyName = "";
        }

        this._selectedObject = parent;
        this._showObjectProperties(parent, isChild);
    }

    _showObjectProperties(obj, isChild) {
        this._clearPane();
        let targetObject = PropGridHelper.getTargetObject(obj);
        let descriptor = PropGridHelper.getObjectDescriptor(obj);
        let folderMap = this._addFolders(targetObject, descriptor);
        this._addBlades(folderMap, targetObject, isChild, descriptor);
    }

    _clearPane() {
        for (let child of this._tweakpane.children) {
            child.dispose();
        }
    }

    _addBlades(folderMap, targetObject, isChild, objectDescriptor) {
        if (targetObject) {
            if (Array.isArray(targetObject)) {
                // show array elements
                this._addArrayToolbar(targetObject);
                let index = 0;

                for (let [name, value] of Object.entries(targetObject)) {
                    let blade = this._addBlade(folderMap, targetObject, name, value, objectDescriptor);
                    this._prepareArrayBlade(blade, value, index);
                    index++;
                }
            } else {
                // show object properties
                let entries = Object.entries(targetObject);

                if (objectDescriptor && objectDescriptor.sorted) {
                    entries.sort(([nameA], [nameB]) => {
                        let displayNameA = objectDescriptor.get(nameA)?.displayName ?? nameA;
                        let displayNameB = objectDescriptor.get(nameB)?.displayName ?? nameB;
                        return displayNameA.localeCompare(displayNameB);
                    });
                }

                for (let [name, value] of entries) {
                    this._addBlade(folderMap, targetObject, name, value, objectDescriptor);
                }
            }
        }

        // add the Back button
        if (isChild) {
            this._tweakpane
                .addButton({
                    title: this._phrases.backButton
                })
                .on("click", () => {
                    this._selectParentObject();
                });
        }
    }

    _addBlade(folderMap, targetObject, propertyName, propertyValue, objectDescriptor) {
        let propertyDescriptor = objectDescriptor?.get(propertyName);

        if (propertyDescriptor && !propertyDescriptor.isBrowsable) {
            return;
        }

        let blade = null;
        let container = this._selectContainer(folderMap, propertyDescriptor);

        if (PropGridDialogs.editorSupported(propertyDescriptor)) {
            // property editor called by button click
            blade = container
                .addButton({
                    label: propertyDescriptor.displayName,
                    title: this._getEditButtonText(propertyValue)
                })
                .on("click", () => {
                    PropGridDialogs.showEditor(this._topObject, propertyValue, propertyDescriptor,
                        (newPropertyValue) => {
                            propertyValue = newPropertyValue;
                            blade.title = this._getEditButtonText(propertyValue);
                            targetObject[propertyName] = propertyValue;
                            this._handleBindingChange(targetObject, propertyName, propertyValue);
                        });
                });
        } else if (typeof propertyValue === "number" ||
            typeof propertyValue === "string" ||
            typeof propertyValue === "boolean") {
            // simple property is editable in row
            blade = container
                .addBinding(targetObject, propertyName, this._getBindingOptions(propertyDescriptor))
                .on("change", (event) => {
                    if (event.last) {
                        this._handleBindingChange(targetObject, propertyName, event.value);
                    }
                });
        } else if (propertyValue instanceof Object) {
            let proxyObject = this._createProxyObject(propertyValue, propertyDescriptor);

            if (proxyObject) {
                // use proxy object
                blade = container
                    .addBinding({ [propertyName]: proxyObject }, propertyName,
                        this._getBindingOptions(propertyDescriptor))
                    .on("change", (event) => {
                        if (event.last) {
                            this._handleBindingChange(targetObject, propertyName, event.value);
                        }
                    });
            } else {
                // complex property requires braking into simple properties
                blade = container
                    .addButton({
                        label: propertyDescriptor ? propertyDescriptor.displayName : propertyName,
                        title: this._getEditButtonText(propertyValue)
                    })
                    .on("click", () => {
                        this._selectChildObject(propertyName, propertyValue);
                    });
            }
        }

        return blade;
    }

    _addFolders(targetObject, objectDescriptor) {
        let folderMap = new Map();

        if (targetObject && objectDescriptor) {
            // get distinct categories
            let categorySet = new Set();

            for (let propertyDescriptor of objectDescriptor.propertyDescriptors.values()) {
                if (Object.hasOwn(targetObject, propertyDescriptor.name) &&
                    propertyDescriptor.isBrowsable && propertyDescriptor.category) {
                    categorySet.add(propertyDescriptor.category);
                }
            }

            // create folders
            for (let category of Array.from(categorySet).sort()) {
                folderMap.set(category, this._tweakpane.addFolder({
                    title: category
                }));
            }
        }

        return folderMap;
    }

    _selectContainer(folderMap, propertyDescriptor) {
        return propertyDescriptor && propertyDescriptor.category
            ? folderMap.get(propertyDescriptor.category) ?? this._tweakpane
            : this._tweakpane;
    }

    _getEditButtonText(propertyValue) {
        const MaxLength = 20;
        let showValue = false;
        let displayValue = "";

        if (propertyValue instanceof Object) {
            showValue = "displayValue" in propertyValue;
            displayValue = propertyValue.displayValue;
        } else {
            showValue = !!propertyValue?.toString;
            displayValue = propertyValue?.toString?.();
        }

        if (showValue) {
            let text = displayValue?.trimStart();
            return text
                ? (text.length <= MaxLength ? text : text.substring(0, MaxLength) + "...")
                : this._phrases.notSet;
        } else {
            return this._phrases.editButton;
        }
    }

    _createProxyObject(propertyValue, propertyDescriptor) {
        if (!propertyDescriptor) {
            return null;
        }

        const BasicType = rs.mimic.BasicType;
        const Subtype = rs.mimic.Subtype;
        let proxy = null;

        if (propertyDescriptor.type === BasicType.STRUCT) {
            switch (propertyDescriptor.subtype) {
                case Subtype.POINT:
                    proxy = new PointProxy(propertyValue);
                    break;

                case Subtype.SIZE:
                    proxy = new SizeProxy(propertyValue);
                    break;
            }
        }

        return proxy;
    }

    _getBindingOptions(propertyDescriptor) {
        if (!propertyDescriptor) {
            return null;
        }

        const BasicType = rs.mimic.BasicType;
        const Subtype = rs.mimic.Subtype;

        let bindingOptions = {
            label: propertyDescriptor.displayName
        };

        if (propertyDescriptor.isReadOnly) {
            bindingOptions.readonly = true;
            bindingOptions.interval = 0;
        }

        switch (propertyDescriptor.type) {
            case BasicType.INT:
                bindingOptions.format = (v) => v.toFixed();
                bindingOptions.step = 1;
                break;

            case BasicType.STRING:
                bindingOptions.view = "text";
                break;

            case BasicType.STRUCT:
                if (propertyDescriptor.subtype === Subtype.POINT ||
                    propertyDescriptor.subtype === Subtype.SIZE) {
                    bindingOptions.x = { step: 1 };
                    bindingOptions.y = { step: 1 };
                }
                break;
        }

        Object.assign(bindingOptions, propertyDescriptor.tweakpaneOptions);
        return bindingOptions;
    }

    _handleBindingChange(targetObject, propertyName, propertyValue) {
        // get value from proxy object
        if (propertyValue instanceof ProxyObject) {
            propertyValue = propertyValue.target;
        }

        // get top property name and value
        let topTargetObject = PropGridHelper.getTargetObject(this._topObject);
        let topPropertyName = "";
        let topPropertyValue = null;

        if (topTargetObject === targetObject) {
            topPropertyName = propertyName;
            topPropertyValue = propertyValue;
        } else if (topTargetObject) {
            topPropertyName = this._topPropertyName;
            topPropertyValue = topTargetObject[this._topPropertyName];
        }

        // update union object
        if (this._topObject instanceof UnionObject) {
            this._topObject.setProperty(topPropertyName, topPropertyValue);
        }

        // call event
        this._eventSource.dispatchEvent(new CustomEvent(PropGridEventType.PROPERTY_CHANGED, {
            detail: {
                selectedObject: this._selectedObject,
                topObject: this._topObject,
                targetObject: targetObject,
                propertyName: propertyName,
                propertyValue: propertyValue,
                topPropertyName: topPropertyName,
                topPropertyValue: topPropertyValue
            }
        }));
    }

    _handleError(message) {
        console.error(message);

        this._eventSource.dispatchEvent(new CustomEvent(PropGridEventType.ERROR, {
            detail: {
                message: message
            }
        }));
    }

    _addArrayToolbar(array) {
        this._tweakpane.addBlade({
            view: "buttongrid",
            size: [4, 1],
            cells: (x, y) => ({
                title: [[
                    this._phrases.addButton,
                    this._phrases.upButton,
                    this._phrases.downButton,
                    this._phrases.deleteButton
                ]][y][x]
            })
        }).on("click", (event) => {
            switch (event.index[0]) {
                case 0:
                    this._addArrayItem(array);
                    break;
                case 1:
                    this._moveUpArrayItem(array);
                    break;
                case 2:
                    this._moveDownArrayItem(array);
                    break;
                case 3:
                    this._deleteArrayItem(array);
                    break;
            }
        });
    }

    _prepareArrayBlade(blade, item, index) {
        if (blade) {
            blade.label = item.displayName || this._phrases.arrayItem + index;
            $(blade.element).addClass("rs-array-item").attr("data-rs-index", index);
        }
    }

    _getSelectedIndex() {
        let index = this._tweakpaneElem.find(".rs-array-item.rs-selected:first").data("rs-index");
        return index >= 0 ? index : -1;
    }

    _setSelectedIndex(index) {
        let itemElems = this._tweakpaneElem.find(".rs-array-item");
        itemElems.removeClass("rs-selected");
        itemElems.filter(`[data-rs-index="${index}"]`).addClass("rs-selected");
    }

    _addArrayItem(array) {
        let itemAdded = false;

        if (array.createItem instanceof Function) {
            let item = array.createItem();

            if (item !== undefined && item !== null) {
                let index = this._getSelectedIndex();

                if (index < 0) {
                    index = array.length;
                    array.push(item);
                } else {
                    index++;
                    array.splice(index, 0, item);
                }

                this._handleArrayChange(array);
                this.refresh();
                this._setSelectedIndex(index);
                itemAdded = true;
            }
        }

        if (!itemAdded) {
            this._handleError(this._phrases.unableAddItem);
        }
    }

    _moveUpArrayItem(array) {
        let index = this._getSelectedIndex();

        if (index > 0) {
            [array[index - 1], array[index]] = [array[index], array[index - 1]];
            this._handleArrayChange(array);
            this.refresh();
            this._setSelectedIndex(index - 1);
        } else {
            this._handleError(this._phrases.unableMoveItem);
        }
    }

    _moveDownArrayItem(array) {
        let index = this._getSelectedIndex();

        if (0 <= index && index < array.length - 1) {
            [array[index], array[index + 1]] = [array[index + 1], array[index]];
            this._handleArrayChange(array);
            this.refresh();
            this._setSelectedIndex(index + 1);
        } else {
            this._handleError(this._phrases.unableMoveItem);
        }
    }

    _deleteArrayItem(array) {
        let index = this._getSelectedIndex();

        if (index >= 0) {
            array.splice(index, 1);
            this._handleArrayChange(array);
            this.refresh();
        } else {
            this._handleError(this._phrases.unableDeleteItem);
        }
    }

    _handleArrayChange(array) {
        this._handleBindingChange(array, "", null);
    }

    get selectedObject() {
        return this._selectedObject;
    }

    set selectedObject(value) {
        if (this._selectedObject !== value) {
            this._selectObject(value);
        }
    }

    get selectedObjects() {
        return this._selectedObject instanceof UnionObject
            ? this._selectedObject.targets
            : [this._selectedObject];
    }

    set selectedObjects(value) {
        if (Array.isArray(value)) {
            if (value.length === 0) {
                this.selectedObject = null;
            } else if (value.length === 1) {
                this.selectedObject = value[0];
            } else {
                this.selectedObject = new UnionObject(value);
            }
        }
    }

    addEventListener(type, listener) {
        this._eventSource.addEventListener(type, listener);
    }

    refreshProperty(propertyName) {
        for (let folder of this._tweakpane.children) {
            for (let binding of folder.children) {
                if (binding.key === propertyName) {
                    binding.refresh();
                    return;
                }
            }
        }
    }

    refresh(opt_backToTop) {
        if (this._selectedObject instanceof UnionObject) {
            let newUnion = new UnionObject(this._selectedObject.targets);
            this._selectObject(newUnion);
        } else if (opt_backToTop) {
            this._selectObject(this._topObject);
        } else {
            let isChild = this._selectedObject !== this._topObject;
            this._showObjectProperties(this._selectedObject, isChild);
        }
    }
}

// Specifies the event types for property grid.
class PropGridEventType {
    static ERROR = "error";
    static PROPERTY_CHANGED = "propertyChanged";
}

// Provides helper methods for property grid.
class PropGridHelper {
    static _translationRef = null;

    static _translateObject(objectDescriptor, translation, objectDict, opt_fallbackDict) {
        if (!objectDict) {
            return;
        }

        const BasicType = rs.mimic.BasicType;

        for (let propertyDescriptor of objectDescriptor.propertyDescriptors.values()) {
            // translate display name and category
            let displayName = objectDict[propertyDescriptor.name] ??
                (opt_fallbackDict ? opt_fallbackDict[propertyDescriptor.name] : "");
            let category = translation.category[propertyDescriptor.category];

            if (displayName) {
                propertyDescriptor.displayName = displayName;
            }

            if (category) {
                propertyDescriptor.category = category;
            }

            // translate enumeration
            if (propertyDescriptor.type === BasicType.ENUM) {
                let enumDict = translation.enumerations.get(propertyDescriptor.subtype);

                if (enumDict) {
                    propertyDescriptor.tweakpaneOptions ??= {};
                    propertyDescriptor.tweakpaneOptions.options ??= enumDict;
                }
            }
        }
    }

    static translateDescriptors(translation) {
        const DescriptorSet = rs.mimic.DescriptorSet;
        PropGridHelper._translationRef = translation;

        // translate mimic and basic component
        PropGridHelper._translateObject(DescriptorSet.mimicDescriptor, translation, translation.mimic);
        PropGridHelper._translateObject(DescriptorSet.componentDescriptor, translation, translation.component);

        // translate components
        for (let [typeName, descriptor] of DescriptorSet.componentDescriptors) {
            PropGridHelper._translateObject(descriptor, translation,
                translation.components.get(typeName), translation.component);
        }

        // translate structures
        for (let [typeName, descriptor] of DescriptorSet.structureDescriptors) {
            PropGridHelper._translateObject(descriptor, translation, translation.structures.get(typeName));
        }
    }

    static getTargetObject(obj) {
        if (obj instanceof rs.mimic.Mimic) {
            return obj.document;
        } else if (obj instanceof rs.mimic.Component) {
            return obj.properties;
        } else if (obj instanceof UnionObject) {
            return obj.properties;
        } else if (obj instanceof Object) {
            return obj;
        } else {
            return null;
        }
    }

    static getObjectDescriptor(obj) {
        const DescriptorSet = rs.mimic.DescriptorSet;

        if (obj instanceof rs.mimic.FaceplateInstance) {
            let descriptor = DescriptorSet.getFaceplateDescriptor(obj.model);
            let translation = PropGridHelper._translationRef;

            if (translation) {
                PropGridHelper._translateObject(descriptor, translation, translation.component);
            }

            return descriptor;
        } else if (obj instanceof rs.mimic.Component) {
            return DescriptorSet.componentDescriptors.get(obj.typeName) ?? DescriptorSet.componentDescriptor;
        } else if (obj instanceof rs.mimic.Mimic) {
            return DescriptorSet.mimicDescriptor;
        } else if (obj instanceof UnionObject) {
            return obj.descriptor;
        } else if (obj instanceof Object && obj.typeName) {
            return DescriptorSet.structureDescriptors.get(obj.typeName);
        } else {
            return null;
        }
    }
}

// Calls property editors implemented as modal dialogs.
class PropGridDialogs {
    static editorMap = new Map();

    // Adds the modal to the editor map.
    static addEditor(key, modal) {
        PropGridDialogs.editorMap.set(key, modal);
    }

    // Checks whether an editor for the specified property is supported.
    static editorSupported(propertyDescriptor) {
        return !!PropGridDialogs.editorMap.get(propertyDescriptor?.editor);
    }

    // Shows an editor as a modal dialog.
    // callback is a function (newPropertyValue)
    static showEditor(topObject, propertyValue, propertyDescriptor, callback) {
        let modal = PropGridDialogs.editorMap.get(propertyDescriptor?.editor);
        modal?.show(
            new ModalShowArgs({
                topObject: topObject,
                value: propertyValue,
                options: propertyDescriptor?.editorOptions
            }),
            modalContext => {
                if (modalContext.result && callback instanceof Function) {
                    callback(modalContext.newValue);
                }
            }
        );
    }
}

// Represents an intermediate object intended for editing.
class ProxyObject {
    target;

    constructor(target) {
        if (target == null) {
            throw new Error("Target must not be null.");
        }

        this.target = target;
    }
}

// Represents a proxy object for editing point as a Point2d.
class PointProxy extends ProxyObject {
    get x() {
        return Number.parseInt(this.target.x) || 0;
    }

    set x(value) {
        this.target.x = value;
    }

    get y() {
        return Number.parseInt(this.target.y) || 0;
    }

    set y(value) {
        this.target.y = value;
    }
}

// Represents a proxy object for editing size as a Point2d.
class SizeProxy extends ProxyObject {
    get x() {
        return Number.parseInt(this.target.width) || 0;
    }

    set x(value) {
        this.target.width = value;
    }

    get y() {
        return Number.parseInt(this.target.height) || 0;
    }

    set y(value) {
        this.target.height = value;
    }
}

// Represents an intermediate object for editing multiple objects.
class UnionObject {
    targets;    // objects included in the union
    properties; // common properties
    descriptor; // describes the union properties

    constructor(targets) {
        if (!Array.isArray(targets)) {
            throw new Error("Targets must be an array.");
        }

        this.targets = targets;
        this._buildProperties();
    }

    _buildProperties() {
        this.properties = {};
        this.descriptor = new rs.mimic.ObjectDescriptor();
        let index = 0;

        for (let target of this.targets) {
            let targetDescriptor = PropGridHelper.getObjectDescriptor(target);
            let editableObj = this._getEditableObject(target);

            if (index === 0) {
                // add properties of the 1st object
                for (let [name, value] of Object.entries(editableObj)) {
                    this.properties[name] = ScadaUtils.deepClone(value, true);
                    let propertyDescriptor = targetDescriptor?.get(name);

                    if (propertyDescriptor && propertyDescriptor.type !== rs.mimic.BasicType.LIST) {
                        this.descriptor.add(propertyDescriptor);
                    }
                }
            } else {
                // intersect with properties of other objects
                for (let [name, value] of Object.entries(this.properties)) {
                    let descriptor1 = this.descriptor.get(name);
                    let descriptor2 = targetDescriptor?.get(name);

                    if (Object.hasOwn(editableObj, name) && this._sameProperties(descriptor1, descriptor2)) {
                        let value2 = editableObj[name];

                        if (!this._sameValues(value, value2)) {
                            // objects have the same property with different values
                            this.properties[name] = this._mergeValues(value, value2);
                        }
                    } else {
                        delete this.properties[name];
                        this.descriptor.delete(name);
                    }
                }
            }

            index++;
        }
    }

    _getEditableObject(target) {
        if (target instanceof rs.mimic.Mimic) {
            return target.document;
        } else if (target instanceof rs.mimic.Component) {
            return target.properties;
        } else if (target instanceof Object) {
            return target;
        } else {
            return {};
        }
    }

    _sameProperties(descriptor1, descriptor2) {
        return descriptor1 === descriptor2 ||
            descriptor1 && descriptor2 && descriptor1.type === descriptor2.type &&
            descriptor1.subtype === descriptor2.subtype;
    }

    _sameValues(value1, value2) {
        let json1 = JSON.stringify(value1);
        let json2 = JSON.stringify(value2);
        return json1 === json2;
    }

    _mergeValues(value1, value2) {
        if (Array.isArray(value1)) {
            return null; // do not display array properties
        } else if (typeof value1 === "number") {
            return value1 === value2 ? value1 : 0;
        } else if (typeof value1 === "string") {
            return value1 === value2 ? value1 : "";
        } else if (typeof value1 === "boolean") {
            return value1 === value2 ? value1 : false;
        } else if (value1 instanceof Object) {
            let result = {};

            for (let [name, value] of Object.entries(value1)) {
                result[name] = this._mergeValues(value, value2[name]);
            }

            return result;
        } else {
            return null; // property is not displayed
        }
    }

    setProperty(name, value) {
        for (let target of this.targets) {
            let editableObj = this._getEditableObject(target);
            editableObj[name] = ScadaUtils.deepClone(value, true);
        }
    }

    toString() {
        return "Union";
    }
}

// Contains classes: StructTree, StructTreeEventType
// Depends on jquery, bootstrap, mimic-model.js

// Represents a component for displaying mimic structure.
class StructTree {
    _eventSource = document.createElement("struct-tree");

    structElem;
    mimic;
    phrases;

    constructor(elemID, mimic, phrases) {
        this.structElem = $("#" + elemID);
        this.mimic = mimic;
        this.phrases = phrases ?? {};
    }

    _prepareDependencies(listElem) {
        let dependenciesNode = $("<span class='node node-dependencies'></span>");
        $("<span class='node-text'></span>").text(this.phrases.dependenciesNode).appendTo(dependenciesNode);
        $("<span class='node-btn add-btn'><i class='fa-solid fa-plus'></i></span>").appendTo(dependenciesNode);

        let dependenciesItem = $("<li class='item-dependencies'></li>").append(dependenciesNode).appendTo(listElem);
        let dependenciesList = $("<ul class='list-dependencies'></ul>").appendTo(dependenciesItem);
        this._appendDependencies(dependenciesList);
    }

    _appendDependencies(listElem) {
        for (let dependency of this.mimic.dependencies) {
            if (!dependency.isTransitive) {
                let dependencyNode = $("<span class='node node-dependency'></span>");
                $("<span class='node-text'></span>").text(dependency.typeName)
                    .appendTo(dependencyNode);
                $("<span class='node-btn edit-btn'><i class='fa-solid fa-pen-to-square'></i></span>")
                    .appendTo(dependencyNode);
                $("<span class='node-btn remove-btn'><i class='fa-regular fa-trash-can'></i></span>")
                    .appendTo(dependencyNode);
                $("<li class='item-dependency'></li>")
                    .attr("data-name", dependency.typeName)
                    .addClass(dependency.hasError ? "has-error" : "")
                    .append(dependencyNode).appendTo(listElem);
            }
        }
    }

    _prepareImages(listElem) {
        let imagesNode = $("<span class='node node-images'></span>");
        $("<span class='node-text'></span>").text(this.phrases.imagesNode).appendTo(imagesNode);
        $("<span class='node-btn add-btn'><i class='fa-solid fa-plus'></i></span>").appendTo(imagesNode);

        let imagesItem = $("<li class='item-images'></li>").append(imagesNode).appendTo(listElem);
        let imagesList = $("<ul class='list-images'></ul>").appendTo(imagesItem);
        this._appendImages(imagesList);
    }

    _appendImages(listElem) {
        for (let image of this.mimic.images) {
            let imageNode = $("<span class='node node-image'></span>");
            $("<span class='node-text'></span>").text(image.name)
                .appendTo(imageNode);
            let viewBtn = $("<span class='node-btn view-btn'><i class='fa-regular fa-eye'></i></span>")
                .appendTo(imageNode);
            $("<span class='node-btn edit-btn'><i class='fa-solid fa-pen-to-square'></i></span>")
                .appendTo(imageNode);
            $("<span class='node-btn remove-btn'><i class='fa-regular fa-trash-can'></i></span>")
                .appendTo(imageNode);
            $("<li class='item-image'></li>").attr("data-name", image.name)
                .append(imageNode).appendTo(listElem);
            this._initImagePopover(viewBtn, image.name);
        }
    }

    _initImagePopover(buttonElem, imageName) {
        const thisObj = this;

        bootstrap.Popover.getOrCreateInstance(buttonElem[0], {
            html: true,
            placement: "bottom",
            trigger: "hover",
            content: function () {
                // called twice by Bootstrap on each show
                let popoverContent = buttonElem.data("popoverContent");

                if (!popoverContent) {
                    let dataUrl = thisObj.mimic.imageMap.get(imageName)?.dataUrl;
                    popoverContent = dataUrl
                        ? `<img class="image-preview" src="${dataUrl}" />`
                        : thisObj.phrases.noImagePreview;
                    buttonElem.data("popoverContent", popoverContent);
                }

                return popoverContent;
            }
        });
    }

    _prepareComponents(listElem) {
        let mimicNode = $("<span class='node node-mimic'></span>").text(this.phrases.mimicNode);
        let mimicItem = $("<li class='item-mimic'></li>").append(mimicNode).appendTo(listElem);
        let componentsList = $("<ul class='list-components'></ul>").appendTo(mimicItem);
        this._appendComponents(componentsList);
    }

    _appendComponents(listElem) {
        for (let component of this.mimic.children) {
            this._appendComponent(listElem, component);
        }
    }

    _appendComponent(listElem, component) {
        let componentNode = $("<span class='node node-comp'></span>").text(component.displayName);
        let componentItem = $("<li class='item-comp'></li>")
            .attr("id", "struct-comp-item" + component.id)
            .attr("data-id", component.id)
            .addClass(component.hasError ? "has-error" : "")
            .append(componentNode).appendTo(listElem);

        if (component.isContainer) {
            let childList = $("<ul></ul>").appendTo(componentItem);

            for (let childComponent of component.children) {
                this._appendComponent(childList, childComponent);
            }
        }

        if (component.isSelected) {
            componentNode.addClass("selected");
        }
    }

    _bindEvents(listElem) {
        const thisObj = this;

        // dependencies
        listElem.find(".item-dependencies")
            .on("click", ".add-btn", function () {
                thisObj._eventSource.dispatchEvent(new CustomEvent(StructTreeEventType.ADD_DEPENDENCY_CLICK));
            })
            .on("click", ".edit-btn", function () {
                thisObj._eventSource.dispatchEvent(new CustomEvent(StructTreeEventType.EDIT_DEPENDENCY_CLICK, {
                    detail: { name: $(this).closest(".item-dependency").data("name") }
                }));
            })
            .on("click", ".remove-btn", function () {
                thisObj._eventSource.dispatchEvent(new CustomEvent(StructTreeEventType.REMOVE_DEPENDENCY_CLICK, {
                    detail: { name: $(this).closest(".item-dependency").data("name") }
                }));
            });

        // images
        listElem.find(".item-images")
            .on("click", ".add-btn", function () {
                thisObj._eventSource.dispatchEvent(new CustomEvent(StructTreeEventType.ADD_IMAGE_CLICK));
            })
            .on("click", ".edit-btn", function () {
                thisObj._eventSource.dispatchEvent(new CustomEvent(StructTreeEventType.EDIT_IMAGE_CLICK, {
                    detail: { name: $(this).closest(".item-image").data("name") }
                }));
            })
            .on("click", ".remove-btn", function () {
                thisObj._eventSource.dispatchEvent(new CustomEvent(StructTreeEventType.REMOVE_IMAGE_CLICK, {
                    detail: { name: $(this).closest(".item-image").data("name") }
                }));
            });

        // mimic and components
        listElem.find(".item-mimic")
            .on("click", ".node-mimic", function () {
                thisObj._eventSource.dispatchEvent(new CustomEvent(StructTreeEventType.MIMIC_CLICK));
            })
            .on("click", ".node-comp", function (event) {
                thisObj._eventSource.dispatchEvent(new CustomEvent(StructTreeEventType.COMPONENT_CLICK, {
                    detail: {
                        componentID: $(this).parent().data("id"),
                        isSelected: $(this).hasClass("selected"),
                        ctrlKey: event.ctrlKey
                    }
                }));
            });
    }

    _findMimicItem() {
        return this.structElem.find(".item-mimic");
    }

    _findComponentItem(component) {
        return this.structElem.find("#struct-comp-item" + component.id);
    }

    _findComponentNode(component) {
        return this._findComponentItem(component).children(".node");
    }

    build() {
        let listElem = $("<ul class='list-top'></ul>");
        this._prepareDependencies(listElem);
        this._prepareImages(listElem);
        this._prepareComponents(listElem);
        this._bindEvents(listElem);

        // add new or replace existing element
        let oldListElem = this.structElem.find(".list-top:first");

        if (oldListElem.length > 0) {
            oldListElem.replaceWith(listElem);
        } else {
            this.structElem.append(listElem);
        }
    }

    refreshDependencies() {
        let oldDependenciesList = this.structElem.find(".list-dependencies:first");
        let newDependenciesList = $("<ul class='list-dependencies'></ul>");
        this._appendDependencies(newDependenciesList);
        oldDependenciesList.replaceWith(newDependenciesList);
    }

    refreshImages() {
        let oldImagesList = this.structElem.find(".list-images:first");
        let newImagesList = $("<ul class='list-images'></ul>");
        this._appendImages(newImagesList);
        oldImagesList.replaceWith(newImagesList);
    }

    refreshComponents(parent) {
        if (parent instanceof rs.mimic.Mimic) {
            // refresh all components
            let oldListElem = this.structElem.find(".list-components:first");
            let newListElem = $("<ul class='list-components'></ul>");
            this._appendComponents(newListElem)
            oldListElem.replaceWith(newListElem);
        } else if (parent instanceof rs.mimic.Component) {
            // refresh components belongs to the parent
            let parentItem = this._findComponentItem(parent);
            let oldListElem = parentItem.children("ul:first");
            let newListElem = $("<ul></ul>");

            for (let childComponent of parent.children) {
                this._appendComponent(newListElem, childComponent);
            }

            oldListElem.replaceWith(newListElem);
        }
    }

    addComponent(component) {
        let listElem = component.parentID > 0
            ? this.structElem.find(`#struct-comp-item${component.parentID}>ul`) 
            : this.structElem.find(".item-mimic>ul");

        if (listElem.length > 0) {
            this._appendComponent(listElem, component);
        }
    }

    updateComponent(component) {
        this._findComponentNode(component).text(component.displayName);
    }

    removeComponent(componentID) {
        this.structElem.find("#struct-comp-item" + componentID).remove();
    }

    selectMimic() {
        let mimicItem = this._findMimicItem();
        mimicItem.children(".node").addClass("selected");
        mimicItem.children("ul").find(".node").removeClass("selected");
    }

    selectNone() {
        this._findMimicItem().find(".node").removeClass("selected");
    }

    selectComponents(components) {
        this.selectNone();

        if (Array.isArray(components)) {
            for (let component of components) {
                this._findComponentNode(component).addClass("selected");
            }
        }
    }

    addToSelection(component) {
        this._findMimicItem().children(".node").removeClass("selected");
        this._findComponentNode(component).addClass("selected");
    }

    removeFromSelection(component) {
        this._findComponentNode(component).removeClass("selected");
    }

    addEventListener(type, listener) {
        this._eventSource.addEventListener(type, listener);
    }
}

// Specifies the event types for a mimic structure component.
class StructTreeEventType {
    static ADD_DEPENDENCY_CLICK = "addDependencyClick";
    static EDIT_DEPENDENCY_CLICK = "editDependencyClick";
    static REMOVE_DEPENDENCY_CLICK = "removeDependencyClick";

    static ADD_IMAGE_CLICK = "addImageClick";
    static EDIT_IMAGE_CLICK = "editImageClick";
    static REMOVE_IMAGE_CLICK = "removeImageClick";

    static MIMIC_CLICK = "mimicClick";
    static COMPONENT_CLICK = "componentClick";
}
