Source: xxe/view/ImageViewport.js

 class ReferenceImageDialog extends XUI.Dialog {
    static showDialog(imageData, imageURI, resourceType, docURI,
                      reference=null) {
        // When the image is dropped, imageURI is simply the image file name.
        // Otherwise, the image has been "opened" (that is, selected using a
        // dialog box) and it's an absolute local or remote file URI.
        
        return new Promise((resolve, reject) => { 
            let dialog = new ReferenceImageDialog(imageData, imageURI,
                                                  resourceType, docURI,
                                                  resolve);
            dialog.open("center", reference);
        });
    }

    constructor(imageData, imageURI, resourceType, docURI, resolve) {
        super({ title: "Change Image",
                movable: true, resizable: true, closeable: true,
                template: ReferenceImageDialog.TEMPLATE,
                buttons: [ { label: "Cancel", action: "cancelAction" },
                           { label: "OK", action: "okAction",
                             default: true } ]});

        this._imageData = imageData;
        this._resolve = resolve;

        let pane = this.contentPane.firstElementChild;
        this._refAsToggle = pane.querySelector(".xxe-refimg-refas-toggle");
        let id = XUI.Util.uid();
        this._refAsToggle.setAttribute("id", id);
        
        let refAsLabel = this._refAsToggle.nextElementSibling;
        refAsLabel.setAttribute("for", id);
        
        this._parentURIText = pane.querySelector(".xxe-refimg-parent-uri");
        this._fileNameText = pane.querySelector(".xxe-refimg-file-name");

        let parentURI, fileName;
        if (URIUtil.isAbsoluteURI(imageURI)) {
            let uri = URIUtil.relativizeURI(imageURI, docURI);
            parentURI = URIUtil.uriParent(uri, /*trailingSepar*/ false);
            if (parentURI === null) {
                // Example: /foo.png relative to /bar.xml ==> uri=foo.png
                // whose parent is null.
                parentURI = "."; 
            }
            fileName = URIUtil.uriBasename(uri);
        } else {
            // Happens when an image is dropped onto the viewport.
            parentURI = null; // Must be specified by user.
            // Just a file name, not an absolute URI.
            fileName = imageURI;
        }
        
        let addDataList = true;
        if (parentURI !== null) {
            this._parentURIText.value = parentURI;
            this._parentURIText.setAttribute("readonly", "readonly");
            addDataList = false;
        }
        this.rememberParentURI(parentURI, addDataList);
        
        if (fileName) {
            this._fileNameText.value = fileName;
            this._fileNameText.setAttribute("readonly", "readonly");
        }
        // Otherwise, an empty fileName is unlikely.

        let docURILabel = pane.querySelector(".xxe-refimg-doc-uri");
        docURILabel.textContent = docURI;
        
        this._embedToggle = pane.querySelector(".xxe-refimg-embed-toggle");
        id = XUI.Util.uid();
        this._embedToggle.setAttribute("id", id);
        
        let embedLabel = this._embedToggle.nextElementSibling;
        embedLabel.setAttribute("for", id);
        
        const mimeType =
              !imageData.type? "application/octet-stream" : imageData.type;
        const imageSize = URIUtil.formatFileSize(imageData.size);
        let embedHTML = XUI.Util.escapeHTML(embedLabel.textContent);
        embedHTML += " (<code>";
        embedHTML += XUI.Util.escapeHTML(mimeType);
        embedHTML += "</code>, ";
        if (imageData.size <= 1024*1024) {
            embedHTML += imageSize;
        } else {
            embedHTML += "<strong style='color:red'>" + imageSize + "</strong>";
        }
        embedHTML += ")";
        embedLabel.innerHTML = embedHTML;

        if (resourceType === "anyURI") {
            this._refAsToggle.checked = true;
        } else {
            this._embedToggle.checked = true;
            this._refAsToggle.disabled = true;
        }
    }
    
    rememberParentURI(parentURI, addDataList) {
        let valueList = XUI.Util.rememberDatalistItem(
            "XXE.ReferenceImageDialog.lastParentURIs",
            parentURI);
        
        if (addDataList) {
            XUI.Util.attachDatalist(this._parentURIText, valueList,
                                    this.contentPane.firstElementChild);
        }
    }

    dialogClosed(imageInfo) {
        this._resolve(imageInfo);
    }
    
    cancelAction() {
        this.close(null);
    }
    
    okAction() {
        let info = { data: this._imageData };

        if (this._embedToggle.checked) {
            info.embed = true;
        } else {
            let parentURI =
                ReferenceImageDialog.checkPath(this._parentURIText.value);
            if (parentURI.length === 0) {
                XUI.Util.badTextField(this._parentURIText);
                return;
            }
            if (parentURI !== ".") {
                this.rememberParentURI(parentURI, /*addDataList*/ false);
            }
            
            let fileName =
                ReferenceImageDialog.checkPath(this._fileNameText.value);
            if (fileName.length === 0 ||
                fileName.indexOf('/') >= 0) {
                XUI.Util.badTextField(this._fileNameText);
                return;
            }

            // info.referenceAs is the relative or absolute URI of the image.
            if (parentURI === ".") {
                info.referenceAs = fileName;
            } else {
                info.referenceAs = parentURI;
                if (!parentURI.endsWith('/')) {
                    info.referenceAs += "/";
                }
                info.referenceAs += fileName;
            }
        }
        
        this.close(info);
    }

    static checkPath(path) {
        path = path.trim();
        if (path.length === 0) {
            return path;
        }
        
        // Allow the Windows user to type a file path and not an URI.
        if (URIUtil.FILE_PATH_SEPARATOR !== '/' &&
            path.indexOf(URIUtil.FILE_PATH_SEPARATOR) >= 0) {
            path = path.replaceAll(URIUtil.FILE_PATH_SEPARATOR, '/');
        }
        
        if (path.indexOf(' ') >= 0) {
            // This one is a no brainer.
            path = path.replaceAll(' ', "%20");
        }

        return path;
    }
}

ReferenceImageDialog.TEMPLATE = document.createElement("template");
ReferenceImageDialog.TEMPLATE.innerHTML = `
<div class="xxe-refimg-pane">
  <div class="xxe-refimg-refas-pane">
    <input type="radio" name="radio_group" class="xxe-refimg-refas-toggle"/>
    <label class="xxe-refimg-refas-label">Reference image using the
    following URI:</label>
  </div>
  <div class="xxe-refimg-uri-pane">
    <input type="text" size="50" class="xui-control xxe-refimg-parent-uri" 
           spellcheck="false" autocomplete="off" />
    <span class="xxe-refimg-uri-separ">/</span>
    <input type="text" size="20" class="xui-control xxe-refimg-file-name" 
           spellcheck="false" autocomplete="off" />
  </div>
  <div class="xxe-refimg-uri-hint">A relative URI is relative to<br />
  <span class="xxe-refimg-doc-uri"></span><br />Use
  "<b><tt>.</tt></b>" to specify "same directory".</div>
  <div class="xxe-refimg-embed-pane">
    <input type="radio" name="radio_group" class="xxe-refimg-embed-toggle"/>
    <label class="xxe-refimg-embed-label">Embed image</label>
  </div>
</div>
`;

// ===========================================================================

/**
 * An image viewport.
 */
class ImageViewport extends HTMLElement {
    constructor() {
        super();
        
        this._xmlEditor = null;
        this._setImageParams = null;
        
        this._beginResize = this._doResize = this._endResize = null;
        this._editingContextChanged = null;
        this._draggedHandle = null;
        this._imgMinSize = 3*ImageViewport.RESIZER_SIZE;
        this._img = null;
        this._imgW0 = this._imgH0 = -1;
        this._dragX0 = this._dragY0 = -1;
        this._preserveAspect = true;
        
        this._showResizeHandles = this.showResizeHandles.bind(this);
        this.addEventListener("click", this._showResizeHandles);
        
        this._chooseImage = this.chooseImage.bind(this);
        this.addEventListener("dblclick", this._chooseImage);
        this.addEventListener("contextmenu", this._chooseImage);
        
        this._dragOverViewport = this.dragOverViewport.bind(this);
        this.addEventListener("dragover", this._dragOverViewport);
        this._dropImage = this.dropImage.bind(this);
        this.addEventListener("drop", this._dropImage);
    }

    // -----------------------------------
    // showResizeHandles
    // -----------------------------------
    
    showResizeHandles(event) {
        if (this._beginResize === null) {
            this._beginResize = this.beginResize.bind(this);
            this._doResize = this.doResize.bind(this);
            this._endResize = this.endResize.bind(this);
            
            // This test is made just once: first time showResizeHandles is
            // invoked.
            
            const docView = this._xmlEditor.documentView;
            if (docView.getAppEventBinding("resize-image") === null &&
                docView.getAppEventBinding("rescale-image") === null) {
                this.removeEventListener("click", this._showResizeHandles);
                return;
            }
        }
        
        // ---
        
        let img = event.target;
        if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey ||
            event.detail !== 1 || /*Click count*/
            img.localName !== "img" ||
            img.width < 3*ImageViewport.RESIZER_MIN_SIZE ||
            img.height < 3*ImageViewport.RESIZER_MIN_SIZE) { 
            return;
        }

        // Do not stop propagation. Let select element happen normally.
        
        this.enableResize(img, true);
    }

    enableResize(img, enable) {
        if (enable) {
            if (this._editingContextChanged === null) {
                let view = NodeView.lookupView(this);
                let uid = null;
                if (view === null || (uid = NodeView.getUID(view)) === null) {
                    // Should not happen.
                    return;
                }
                
                let resizers = document.createElement("span");
                resizers.setAttribute("class", "xxe-imgvp-resizers");

                let resizerClassPrefix = "xxe-imgvp-resizer xxe-imgvp-handle-";
                let resizerSize = ImageViewport.RESIZER_SIZE;
                if (img.width < 5*ImageViewport.RESIZER_SIZE ||
                    img.height < 5*ImageViewport.RESIZER_SIZE) {
                    resizerSize = ImageViewport.RESIZER_MIN_SIZE;
                    resizerClassPrefix =
                        "xxe-imgvp-min-handle " + resizerClassPrefix;
                }
                
                let handles = [ "ne", "se", "sw", "nw" ];
                if (img.width >= 5*resizerSize) {
                    handles.push("n", "s");
                }
                if (img.height >= 5*resizerSize) {
                    handles.push("w", "e");
                }
                for (let handle of handles) {
                    let resizer = document.createElement("span");
                    resizer.setAttribute("class",
                                         resizerClassPrefix + handle);
                    resizer.addEventListener("mousedown", this._beginResize);
                    resizers.appendChild(resizer);
                }

                let imgParent = img.parentElement;
                imgParent.insertBefore(resizers, img);
                imgParent.removeChild(img);
                resizers.appendChild(img);
                
                this._editingContextChanged = (event) => {
                    // UID, tag, attr_list triplet list.
                    const items = event.nodePathItems;
                    const selectedUID = (items === null)? null :
                          items[items.length-3];
                    if (selectedUID !== uid) {
                        this.enableResize(img, false);
                    }
                }
                this._xmlEditor.addEventListener("editingContextChanged",
                                                 this._editingContextChanged);
            }
        } else {
            if (this._editingContextChanged !== null) {
                this._xmlEditor.removeEventListener("editingContextChanged",
                                                   this._editingContextChanged);
                this._editingContextChanged = null;

                let resizers = img.parentElement;
                if (resizers.classList.contains("xxe-imgvp-resizers")) {
                    resizers.removeChild(img);
                    resizers.parentNode.replaceChild(img, resizers);
                }
            }
        }
    }

    beginResize(event) {
        event.preventDefault();
        event.stopImmediatePropagation();

        if (event.altKey || event.shiftKey ||
            (PLATFORM_IS_MAC_OS && event.ctrlKey) ||
            (!PLATFORM_IS_MAC_OS && event.metaKey)) {
            return;
        }
        
        this._draggedHandle = null;
        this._imgMinSize = 3*ImageViewport.RESIZER_SIZE;
        for (let cls of event.target.classList.values()) {
            if (cls === "xxe-imgvp-min-handle") {
                this._imgMinSize = 2*ImageViewport.RESIZER_MIN_SIZE;
            } else if (cls.startsWith("xxe-imgvp-handle-")) {
                this._draggedHandle = cls.substring(17);
            }
        }
        if (this._draggedHandle === null) {
            // Should not happen.
            return;
        }
        this._img = event.target.parentElement.lastElementChild;
        this._imgW0 = this._img.width;
        this._imgH0 = this._img.height;
        this._dragX0 = event.clientX;
        this._dragY0 = event.clientY;
        this._preserveAspect =
            !(PLATFORM_IS_MAC_OS? event.metaKey : event.ctrlKey);
        const docView = this._xmlEditor.documentView;
        if (this._preserveAspect) {
            if (docView.getAppEventBinding("rescale-image") === null) {
                this._preserveAspect = false;
            }
        } else {
            if (docView.getAppEventBinding("resize-image") === null) {
                this._preserveAspect = true;
            }
        }
        
        document.addEventListener("mousemove", this._doResize);
        document.addEventListener("mouseup", this._endResize);
        document.addEventListener("mouseleave", this._endResize);
    }
    
    doResize(event) {
        event.preventDefault();
        event.stopImmediatePropagation();
        
        // Absolute value (may be zero) and sign of incrX, incrY depends
        // on the handle which is dragged.
        
        let resizingX = false;
        let resizingY = false;
        let incrX = 0;
        let incrY = 0;

        switch (this._draggedHandle) {
        case "nw":
            incrX = this._dragX0 - event.clientX;
            incrY = this._dragY0 - event.clientY;
            resizingX = resizingY = true;
            break;
        case "ne":
            incrX = event.clientX - this._dragX0;
            incrY = this._dragY0 - event.clientY;
            resizingX = resizingY = true;
            break;
        case "se":
            incrX = event.clientX - this._dragX0;
            incrY = event.clientY - this._dragY0;
            resizingX = resizingY = true;
            break;
        case "sw":
            incrX = this._dragX0 - event.clientX;
            incrY = event.clientY - this._dragY0;
            resizingX = resizingY = true;
            break;
        case "n":
            incrY = this._dragY0 - event.clientY;
            resizingY = true;
            break;
        case "e":
            incrX = event.clientX - this._dragX0;
            resizingX = true;
            break;
        case "s":
            incrY = event.clientY - this._dragY0;
            resizingY = true;
            break;
        case "w":
            incrX = this._dragX0 - event.clientX;
            resizingX = true;
            break;
        }

        let newW = this._imgW0 + incrX;
        if (resizingX && newW < this._imgMinSize) {
            newW = this._imgMinSize;
            incrX = this._imgMinSize - this._imgW0;
        }
        let newH = this._imgH0 + incrY;
        if (resizingY && newH < this._imgMinSize) {
            newH = this._imgMinSize;
            incrY = this._imgMinSize - this._imgH0;
        }
        
        let scale = -1;
        if (this._preserveAspect) {
            if (resizingX && resizingY) {
                if (Math.abs(incrX) >= Math.abs(incrY)) {
                    scale = newW / this._imgW0;
                } else {
                    scale =  newH / this._imgH0;
                }
            } else if (resizingX) {
                scale = newW / this._imgW0;
            } else {
                scale = newH / this._imgH0;
            }

            newW = Math.round(this._imgW0 * scale);
            newH = Math.round(this._imgH0 * scale);
        }
        
        if (newW !== this._img.width || newH !== this._img.height) {
            this._img.width = newW;
            this._img.height = newH;
        }
    }
    
    endResize(event) {
        this.doResize(event);

        // ---

        const img = this._img;
        const preserveAspect = this._preserveAspect;
        
        this._draggedHandle = null;
        this._imgMinSize = 3*ImageViewport.RESIZER_SIZE;
        this._img = null;
        this._imgW0 = this._imgH0 = -1;
        this._dragX0 = this._dragY0 = -1;
        this._preserveAspect = true;
        
        document.removeEventListener("mousemove", this._doResize);
        document.removeEventListener("mouseup", this._endResize);
        document.removeEventListener("mouseleave", this._endResize);
        
        this.enableResize(img, false);

        // ---

        const docView = this._xmlEditor.documentView;
        let cmdName = null;
        let cmdParams = null;
        
        let binding = null;
        if (preserveAspect) {
            binding = docView.getAppEventBinding("rescale-image");
        } else {
            binding = docView.getAppEventBinding("resize-image");
        }
        if (binding !== null) {
            cmdName = binding.commandName;
            cmdParams = binding.commandParams;
            if (cmdParams !== null) {
                if (cmdParams.indexOf("%{width}") >= 0) {
                    cmdParams = cmdParams.replaceAll("%{width}",
                                                     String(img.width));
                }
                if (cmdParams.indexOf("%{height}") >= 0) {
                    cmdParams = cmdParams.replaceAll("%{height}",
                                                     String(img.height));
                }
                if (cmdParams.indexOf("%{preserveAspect}") >= 0) {
                    cmdParams = cmdParams.replaceAll("%{preserveAspect}",
                                                     String(preserveAspect));
                }
            }
        }
        
        if (cmdName !== null) {
            docView.executeCommand(EXECUTE_NORMAL, cmdName, cmdParams);
        }
    }
    
    // -----------------------------------
    // chooseImage
    // -----------------------------------
    
    chooseImage(event) {
        event.preventDefault();
        event.stopPropagation();

        let view = NodeView.lookupView(this);
        if (view === null) {
            // Should not happen.
            return;
        }
        
        const xmlEditor = this._xmlEditor;
        const docView = xmlEditor.documentView;
        
        let imageResource = [];
        
        return docView.selectNode(view)
            .then((selected) => {
                if (!selected) {
                    // Not expected to happen.
                    return null;
                } else {
                    const options = {
                        title: "Choose Image",
                        extensions: [ ["Image files",
                                       "image/*",
                                       "png",
                                       "svg",
                                       "jpg", "jpeg", "jpe", "jif", "jfif",
                                       "gif",
                                       "bmp",
                                       "ico",
                                       "webp",
                                       "apng",
                                       "avif"] ]
                    };
                    
                    return xmlEditor.openResource(options);
                }
            })
            .then((resource) => {
                if (resource === null) {
                    // Canceled by user.
                    return null;
                } else {
                    imageResource.push(resource);

                    return ReferenceImageDialog.showDialog(
                        resource.data, resource.uri, this._setImageParams[2],
                        xmlEditor.documentURI, xmlEditor);
                }
            })
            .then((imageInfo) => {
                if (imageInfo === null) {
                    // Canceled by user.
                    return false;
                } else {
                    if (!imageInfo.embed) {
                        xmlEditor.cacheResource(imageResource[0]);
                    }
                    
                    return this.changeImage(imageInfo, xmlEditor);
                }
            })
            .catch((error) => {
                XUI.Alert.showError(`Cannot change image:\n${error}`,
                                    xmlEditor);
                return false;
            });
    }
    
    // -----------------------------------
    // dragOverViewport
    // -----------------------------------
    
    dragOverViewport(event) {
        event.preventDefault();
        event.stopPropagation();

        // No way to be more precise.
        event.dataTransfer.dropEffect =
            (event.dataTransfer.types.indexOf("Files") >= 0)?
            "copyLink" : "none";
    }

    // -----------------------------------
    // dropImage
    // -----------------------------------
    
    dropImage(event) {
        event.preventDefault();
        event.stopPropagation();

        let imageFile = ImageViewport.getDroppedImageFile(event);
        if (imageFile === null) {
            // Dropped file not an image.
            return;
        }
        
        let view = NodeView.lookupView(this);
        if (view === null) {
            // Should not happen.
            return;
        }
        
        const xmlEditor = this._xmlEditor;
        const docView = xmlEditor.documentView;

        let imageInfo = {};
        
        return docView.selectNode(view)
            .then((selected) => {
                if (!selected) {
                    // Not expected to happen.
                    return null;
                } else {
                    // Consider imageFile.name as an URI relative
                    // to an unknown base.
                    return ReferenceImageDialog.showDialog(
                        imageFile, encodeURIComponent(imageFile.name),
                        this._setImageParams[2],
                        xmlEditor.documentURI, xmlEditor);
                }
            })
            .then((dialogInfo) => {
                if (dialogInfo === null) {
                    // Canceled by user.
                    return null; // No resource.
                } else {
                    Object.assign(imageInfo, dialogInfo);
                    
                    if (imageInfo.embed) {
                        return null; // No resource.
                    } else {
                        const uri = URIUtil.resolveURI(imageInfo.referenceAs,
                                                       xmlEditor.documentURI);
                        return xmlEditor.putResource(imageInfo.data, uri);
                    }
                }
            })
            .then((resource) => {
                if (!imageInfo.data) {
                    // Canceled by user.
                    return false;
                } else {
                    return this.changeImage(imageInfo, xmlEditor);
                }
            })
            .catch((error) => {
                XUI.Alert.showError(`Cannot change image:\n${error}`,
                                    xmlEditor);
                return false;
            });
    }

    static getDroppedImageFile(event) {
        let imageFile = null;
        for (let file of event.dataTransfer.files) {
            if (file.type &&
                (file.type.startsWith("image/") ||
                 file.type.startsWith("application/mathml"))) {
                imageFile = file;
                break;
            }
        }

        return imageFile;
    }
    
    changeImage(imageInfo, xmlEditor) {
        // this._setImageParams is an array containing:
        // 0) source_element_UID|-
        // 1) attribute_name|-
        // 2) resource_type: anyURI|hexBinary|base64Binary|XML
        // 3) gzip|-

        const params = this._setImageParams.join(' ');
        let imageValue = [null];

        return ImageViewport.imageValue(imageInfo)
            .then((value) => {
                imageValue[0] = value;
                return xmlEditor.documentView.executeCommand(
                    EXECUTE_HELPER,
                    "changeImage", `${params} ${imageValue[0]}`);
            })
            .then((result) => {
                if (CommandResult.isDone(result)) {
                    return true;
                } else {
                    const reason = CommandResult.formatCommandResult(
                        `changeImage ${params} ${imageValue[0]}`,
                        result);
                    throw new Error(reason);
                }
            })
            .catch((error) => {
                XUI.Alert.showError(`Cannot change image:\n${error}`,
                                    xmlEditor);
                return false;
            });
    }

    static imageValue(imageInfo) {
        // Note that embed and anyURI means: use a "data" URL.
        if (imageInfo.embed) {
            return ImageViewport.blobToDataURL(imageInfo.data);
        } else {
            let value = imageInfo.referenceAs;
            if (value.indexOf("\"") >= 0) {
                value = value.replace(/"/g, "\\\"");
            }
            value = "\"" + value + "\"";
            return Promise.resolve(value);
        }
    }
    
    static blobToDataURL(blob) {
        return new Promise((resolve, reject) => {
            const reader = new FileReader();
            reader.onload = (e) => resolve(reader.result);
            reader.onerror = (e) => reject(reader.error);
            reader.onabort = (e) => reject(new Error("read aborted"));
            reader.readAsDataURL(blob);
        });
    }
     
    // -----------------------------------------------------------------------
    // Custom element
    // -----------------------------------------------------------------------

    connectedCallback() {
        this._xmlEditor = DOMUtil.lookupAncestorByTag(this, "xxe-client");
        if (this._xmlEditor === null) {
            // Should not happen.
            return;
        }
        
        let imgParent = this.firstElementChild;
        if (imgParent === null) {
            // Should not happen.
            return;
        }

        this._setImageParams = this.getAttribute("setimageparam");
        if (this._setImageParams) {
            // 0) source_element_UID|-
            // 1) attribute_name|-
            // 2) resource_type: anyURI|hexBinary|base64Binary|XML
            // 3) gzip|-
            this._setImageParams = this._setImageParams.split(/\s+/);
        }
        if (!Array.isArray(this._setImageParams) ||
            this._setImageParams.length !== 4) {
            this._setImageParams = null;
        }
        
        let contentURI = imgParent.getAttribute("data-src");
        if (contentURI === null) {
            this.setTooltip(imgParent);
            // Nothing else to do.
            return;
        }

        // Attribute data-label (if any) is processed by setTooltip.
        
        imgParent.classList.remove("xxe-imgvp-placeholder");
        
        contentURI = URIUtil.csriURLToURI(contentURI);
        imgParent.removeAttribute("data-src");
        
        let contentWidth = imgParent.getAttribute("data-cw");
        if (contentWidth !== null) {
            imgParent.removeAttribute("data-cw");
        }
        let contentHeight = imgParent.getAttribute("data-ch");
        if (contentHeight !== null) {
            imgParent.removeAttribute("data-ch");
        }

        let preserveAspectRatio = true;
        let par = imgParent.getAttribute("data-par");
        if (par !== null) {
            preserveAspectRatio = (par !== "no");
            imgParent.removeAttribute("data-par");
        }

        // In Chrome: without this timeout, the result of a "loadResource"
        // request (e.g. request #7) may be obtained AFTER the result of a
        // request which follows it (e.g. request #8 "executeCommand
        // editAttributes").
        setTimeout(() => {
            this.addImg(this._xmlEditor,
                        contentURI, contentWidth, contentHeight,
                        preserveAspectRatio, imgParent);
        }, 0 /*ms*/);
    }
    
    setTooltip(imgParent) {
        if (this._setImageParams) {
            let tooltip = imgParent.getAttribute("data-label");
            if (!tooltip) {
                tooltip = "";
            } else {
                tooltip += "\n\n";
                
                // Don't remove attribute "data-label". CSS depends on it.
                imgParent.setAttribute("data-label", ""); 
            }
            tooltip += "\u2022 Drop an image file here.\n\
\u2022 Double-click or right-click to choose an image file.";
            
            this.setAttribute("title", tooltip);
        } else {
            // Not editable.
            this.removeEventListener("dblclick", this._chooseImage);
            this.removeEventListener("contextmenu", this._chooseImage);
            this.removeEventListener("dragover", this._dragOverViewport);
            this.removeEventListener("drop", this._dropImage);
        }
    }
    
    async addImg(xmlEditor,
                 contentURI, contentWidth, contentHeight, preserveAspectRatio,
                 imgParent) {
        await this.doAddImg(xmlEditor, contentURI, contentWidth, contentHeight,
                            preserveAspectRatio, imgParent);
        
        this.setTooltip(imgParent);
    }
    
    async doAddImg(xmlEditor,
                   contentURI, contentWidth, contentHeight, preserveAspectRatio,
                   imgParent) {
        let res = null;
        let getError = null;
        try {
            res = await xmlEditor.getResource(contentURI);
        } catch (error) {
            getError = error;
        }
        if (res === null) {
            if (getError === null) {
                getError = new Error("null resource");
            }
            ImageViewport.noImg(getError, imgParent);
            // Cannot load image. Give up.
            return;
        }
        
        // ---
        
        if (res.data === null) {
            // Image simply cannot be displayed. Not an error. Real placeholder.
            imgParent.classList.add("xxe-imgvp-placeholder");
            imgParent.textContent = CSSIcon["image"];
            return;
        }

        // ---

        let img = document.createElement("img");
        const imgURL = URL.createObjectURL(res.data);
        img.src = imgURL;
        
        let displayError = null;
        try {
            await img.decode();
            
            if (img.naturalWidth <= 0 || img.naturalHeight <= 0) {
                displayError =
                    "the intrinsic dimensions of the image are not known";
            }
        } catch (error) {
            displayError = error.message;
        } finally {
            URL.revokeObjectURL(imgURL);
        }
        if (displayError !== null) {
            ImageViewport.noImg(
                `Cannot display image "${contentURI}":\n${displayError}`,
                imgParent);
            // Give up.
            return;
        }

        if (contentWidth !== null || contentHeight !== null) {
            let [width, widthType] =
                ImageViewport.parseSizeSpec(contentWidth);
            let [height, heightType] =
                ImageViewport.parseSizeSpec(contentHeight);

            let viewportWidth = 0;
            if (this.style.getPropertyValue("width")) {
                // If a viewport width has been specified.
                viewportWidth = this.clientWidth;
            }
            let viewportHeight = 0;
            if (this.style.getPropertyValue("height")) {
                // If a viewport height has been specified.
                viewportHeight = this.clientHeight;
            }
            
            let size = ImageViewport.computeScaledSize(
                viewportWidth, viewportHeight,
                img.naturalWidth, img.naturalHeight,
                width, widthType, height, heightType, preserveAspectRatio);
            if (size !== null) {
                img.width = size[0];
                img.height = size[1];
            }
        }
        
        imgParent.replaceChildren(img);
    }

    static noImg(error, imgParent) {
        imgParent.setAttribute("data-label", error);
        imgParent.classList.add("xxe-imgvp-error");
        imgParent.textContent = CSSIcon["no-image"];
    }
    
    static parseSizeSpec(spec) {
        let size = -1;
        let sizeType = null;

        if (spec !== null) {
            if (spec.endsWith("px")) {
                sizeType = "px";
                size = Number(spec.substring(0, spec.length-2));
            } else if (spec.endsWith("%")) {
                sizeType = "%";
                size = Number(spec.substring(0, spec.length-1));
            } else if (spec.endsWith("max")) {
                sizeType = "max";
                size = Number(spec.substring(0, spec.length-3));
            } else if (spec === "scale-to-fit") {
                sizeType = "fit";
                size = 1; // Any positive value is OK here.
            }

            if (isNaN(size) || size <= 0) {
                size = -1;
            }
        }

        return [ size, sizeType ];
    }
    
    static computeScaledSize(viewportWidth, viewportHeight,
                             imageWidth, imageHeight, 
                             width, widthType, height, heightType,
                             preserveAspectRatio) {
        let specWidth = -1;
        let specHeight = -1;

        if (width > 0) {
            switch (widthType) {
            case "px":
                specWidth = width;
                break;
            case "%":
                specWidth =
                    (imageWidth * width) / 100.0; // imageWidth may be 0.
                break;
            case "fit":
                specWidth = viewportWidth; // viewportWidth may be 0.
                break;
            case "max":
                specWidth = Math.min(imageWidth, width);
                break;
            }
        }

        if (height > 0) {
            switch (heightType) {
            case "px":
                specHeight = height;
                break;
            case "%":
                specHeight =
                    (imageHeight * height) / 100.0; // imageHeight may be 0.
                break;
            case "fit":
                specHeight = viewportHeight; // viewportHeight may be 0.
                break;
            case "max":
                specHeight = Math.min(imageHeight, height);
                break;
            }
        }

        let scaledWidth = -1;
        let scaledHeight = -1;
        let scaleX, scaleY;

        if (specWidth <= 0) {
            if (specHeight <= 0) {
                return null;
            } else {
                scaleY = specHeight / imageHeight;
                scaledWidth = Math.round(imageWidth * scaleY);
                scaledHeight = Math.round(imageHeight * scaleY);
            }
        } else {
            if (specHeight <= 0) {
                scaleX = specWidth / imageWidth;
                scaledWidth = Math.round(imageWidth * scaleX);
                scaledHeight = Math.round(imageHeight * scaleX);
            } else {
                if (((widthType === "fit" && heightType === "fit") ||
                     (widthType === "max" && heightType === "max")) &&
                    preserveAspectRatio) {
                    scaleX = specWidth / imageWidth;
                    scaleY = specHeight / imageHeight;
                    let scale = Math.min(scaleX, scaleY);
                    scaledWidth = Math.round(imageWidth * scale);
                    scaledHeight = Math.round(imageHeight * scale);
                } else {
                    scaledWidth = specWidth;
                    scaledHeight = specHeight;
                }
            }
        }

        return [ scaledWidth, scaledHeight ];
    }

    disconnectedCallback() {
        let img = this.getElementsByTagName("img");
        if (img.length === 1) {
            this.enableResize(img.item(0), false);
        }
    }
}

ImageViewport.RESIZER_MIN_SIZE = 5; /*px*/
ImageViewport.RESIZER_SIZE = 2*ImageViewport.RESIZER_MIN_SIZE;

window.customElements.define("xxe-image-viewport", ImageViewport);