Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save sambarrowclough/27f9321ae4fb6b521268d00cf53db29e to your computer and use it in GitHub Desktop.
Save sambarrowclough/27f9321ae4fb6b521268d00cf53db29e to your computer and use it in GitHub Desktop.

Revisions

  1. sambarrowclough created this gist Aug 11, 2020.
    2,272 changes: 2,272 additions & 0 deletions gistfile1.txt
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,2272 @@
    import tippy, { createSingleton } from "tippy.js";
    import "tippy.js/themes/light-border.css";
    import "tippy.js/dist/tippy.css";
    import "@babel/polyfill";
    import Swal from "sweetalert2/dist/sweetalert2.js";
    import "sweetalert2/src/sweetalert2.scss";
    import "loaders.css"

    // Local
    import * as _ from "./utils";
    import "./Editor.scss";
    import "./libs/pickr/src/scss/themes/nano.scss";
    import Pickr from "./libs/pickr";
    import Croppie from "./libs/Croppie/croppie";
    import "./libs/Croppie/croppie.css";
    import Spinner from "./Spinner";
    import Logger from "./Logger";

    // import data from "./data.json";
    let isDev = __ENV__ === "dev";
    window.log = {
    debug: () => {},
    error: () => {},
    warn: () => {},
    info: () => {}
    }

    class Editor {
    static _croppie = Croppie;
    static _utils = _;
    static _pickr = Pickr;
    static _tippy = tippy;

    static EDITOR_ACTIONS = {
    DONE: "DONE",
    CANCEL: "CANCEL",
    ZOOM: "ZOOM",
    SELECT: "SELECT",
    REPLACE: "REPLACE",
    SETTINGS_DURATION:"SETTINGS_DURATION",
    SETTINGS_START: "SETTINGS_START",
    SETTINGS_RANDOM: "SETTINGS_RANDOM",
    SETTINGS_ARROWS: "SETTINGS_ARROWS",
    SETTINGS_HOVER_PAUSE: "SETTINGS_HOVER_PAUSE",
    SETTINGS_NUMBERED: "SETTINGS_NUMBERED",
    SETTINGS_EFFECT: "SETTINGS_EFFECT",
    SETTINGS_VISIBLE: "SETTINGS_VISIBLE",
    SETTINGS: "SETTINGS",
    URL: "URL",
    CAPTION: "CAPTION",
    PREVIEW: "PREVIEW",
    DELETE: "DELETE",
    ADD: "ADD",
    }

    // Evenlistener name: [callbacks]
    _eventListener = {
    // Actions
    init: [],
    move: [],
    edit: [],
    add: [],
    delete: [],
    select: [],
    publish: [],
    settings:[],
    replace:[],
    url:[],
    caption:[],
    preview:[],
    back: [],
    done:[],
    cancel:[],

    // Logs
    error: [],
    debug: [],
    warn: []
    };

    static DEFAULT_OPTIONS = {
    boundaryHeight: 500,
    boundaryWidth: 1200,

    animSpeed: 0,
    effect: 8,
    nav: true,
    nav2: false,
    pauseOnHover: true,
    pauseTime: 3000,
    next: "Next",
    previous: "Prev",
    randomStart: true,
    startSlide: 0,
    visible: true,
    forceManual: false,
    slides: [
    {
    text: null,
    imageUrl: "https://d13z1xw8270sfc.cloudfront.net/origin/477866/my-img-1595420917353.png",
    url: null,
    linkMode: null
    }
    ]
    }

    _slides = [];
    _slide = null;
    _slideLimit = 10;
    _logo = null;
    _sortables = [];
    _style = {};
    pickr = null;
    _slideInitIndex = 0;

    constructor(opt) {
    log.debug("Begin initialisation");

    log.debug("Resolving options");
    this._resolveOptions(opt)
    .then(() => {
    log.debug("Resolved options");

    log.debug("Binding components");
    return this._bindComponents();
    })
    .then(() => {
    log.debug("Bound component");

    this._emit("init", this);
    })
    .catch((e) => {
    this._emit("error", e);
    });
    }

    _resolveOptions(opt) {



    return new Promise((resolve, reject) => {
    this.namespace = "designeditor";
    // Assign default values
    this.options = this.opt = Object.assign(
    {},
    Editor.DEFAULT_OPTIONS,
    opt
    );

    log.debug("Options:", this.options);

    this._root = {
    editorActions: {
    select: [],
    },
    };

    // Slideshow
    if (opt.slides?.length) {
    log.debug("Setting up slideshow controls");
    this._slides = opt.slides
    } else {
    log.debug("No slides given in options, setting up with default image")
    this._slides = [{ imageUrl: "https://d13z1xw8270sfc.cloudfront.net/origin/477866/plain-white-background_1592919904955.jpg" }]
    }

    if (!opt?.boundaryHeight) {
    log.warn(`Using default boundary width ${Editor.DEFAULT_OPTIONS.boundaryHeight}`)
    }
    if (!opt?.boundaryWidth) {
    log.warn(`Using default boundary width ${Editor.DEFAULT_OPTIONS.boundaryWidth}`)
    }

    resolve();
    });
    }

    _flexLoaded(){
    const url = "javascripts/flexslider/jquery.flexslider-min_v1.js"
    return document.body.innerHTML.search(url) > -1
    }

    async _bindComponents() {
    const { flex, slideshowTarget } = this.options

    // TODO: resolveOptions should set the this.startImageUrl so we are not doing checks like this
    // Set default image as white-background
    if (this._slides && this._slides.length && this._activeSlide() && this._activeSlide().imageUrl) {
    const defaultImage = "https://d13z1xw8270sfc.cloudfront.net/origin/477866/plain-white-background_1592919904955.jpg";
    const url = this._activeSlide().imageUrl
    var isImg = await _.isImg(url);
    if (!isImg) {
    this._activeSlide().imageUrl = defaultImage
    log.warn(`Image [${url}] is not an image. Binding default image to the editor.`)
    }
    } else {
    this._emit("warn", "Found no image in options, binding default image.")
    }

    if (flex) {
    flex.remove();
    log.debug("Removed flex slider");
    await this._buildImageEditorShell().catch(err => this._emit("error", err));
    log.debug("Built image editor shell");
    } else if (flex && !isImg) {
    log.error("Failed to build shideshow. No flex object and startImage is not an image.");
    this._emit("error", err)
    } else {
    await this._buildImageEditorShell().catch(err => this._emit("error", err));
    log.debug("Built image editor shell");
    }

    // Remove default spacing on buttons for editor
    var btns = document.querySelectorAll(".ql-toolbar button");

    for (let index = 0; index < btns.length; index++) {
    const btn = btns[index];

    if (btn instanceof HTMLElement) {
    btn.style.marginBottom = "0px";
    }
    }

    // Styles
    if (this._style.enabled) {
    // Disable main style sheet
    document.querySelector("#design_css_master").disabled = true;

    this._buildStyles();
    }

    // Spotlight
    slideshowTarget.style.zIndex = "1002";
    const overlay = _.createFromTemplate(`<div :ref=overlay id="overlay"></div>`);
    _.inBefore(overlay.overlay, document.body);

    }

    // blob -> https://d13z1xw8270sfc.cloudfront.net/origin/{storeId}/{file}
    async url(blob, storeId) {
    var time = new Date().getTime();

    if (/Edge/.test(navigator.userAgent) || /Trident/.test(navigator.userAgent)) {
    var file = new Blob([blob], { type: "image/png" });
    file.name = "my-img-" + time + ".png";
    } else {
    var file = new File([blob], "my-img-" + time + ".png", {
    type: "image/png",
    });
    }

    const signed = await this.sign(storeId, file.name, file.type, "prodimg")

    return this.upload(file, signed.url, storeId);
    }

    async sign(shopkeeper, fileName, contentType, type = "prodimg") {
    var url = "https://2iiejpzs1a.execute-api.eu-west-1.amazonaws.com/v1/geturl";

    var opts = {
    headers: {
    "content-type": "application/json",
    },
    body: JSON.stringify({
    shopkeeper: shopkeeper,
    fileName: fileName,
    contentType: contentType,
    type: type,
    }),
    method: "POST",
    };

    return fetch(url, opts).then(function (res) {
    if (res.status === 200) {
    return res.json();
    } else {
    throw new Error(res.statusText);
    }
    });
    }

    async upload(file, url, storeId) {
    var opts = {
    headers: {
    "content-type": file.type,
    "x-amz-acl": "public-read",
    },
    body: file,
    method: "PUT",
    };

    return fetch(url, opts).then(function (res) {
    if (res.status === 200) {
    return (
    "https://d13z1xw8270sfc.cloudfront.net/origin/" +
    storeId +
    "/" +
    file.name
    );
    } else {
    throw new Error(res.statusText);
    }
    });
    }

    async _skip(e) {
    log.debug("SKIP:START", this._activeSlide());

    // Get the original image
    this._activeSlide().imageUrl = this._slide.data.url

    // Hide done, cancel actions
    this._hideCropActions()

    // Wait between 0.6s-1.0s
    await new Promise(r => setTimeout(r, Math.floor(Math.random()*300+200)))

    // Reset points
    if (this._slide.backup) {
    const { url } = this._slide.data
    const { points, zoom, orientation } = this._slide.backup
    await this._slide.bind({ url, points, orientation, zoom })
    }

    // Show toolbar, publish settings tools
    this._showEditorActions()
    log.debug("SKIP:END", this._activeSlide());
    }

    _preview(e){
    log.debug("PREVIEW:START", $("#slider1"))

    const { captionInput, urlInput, preview } = this._root.editorActions

    if (preview.classList.contains("disabled")) return

    const slides = this._slides.map((x) => x.imageUrl).filter(Boolean)
    const editor = this._slide.elements.boundary;

    if (captionInput) {
    this._saveCaptionText()
    _.rm(captionInput)
    this._root.editorActions.captionInput = null
    }

    if (urlInput) {
    this._saveUrlText()
    _.rm(urlInput)
    this._root.editorActions.urlInput = null
    }

    if (slides.length === 0) {
    this._emit("error", new Error("PREVIEW_UNAVAILABLE: Slide list is empty. Please Add a slide to use the Preview"))
    return
    }

    if (!this._flexLoaded()) {
    this._emit("error", new Error("PREVIEW_UNAVAILABLE: flex.jquery dependency not loaded. Note: We only load this dependency if you have slides on initialisation"))
    return
    }

    const html = _.createFromTemplate(`
    <div :ref=flexslider class="slider-wrapper theme-default">
    <div id="slider1" class="flexslider">
    <div :ref=back class="btn-group settings slider-prev">
    <button class="btn">
    <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-corner-down-left"><polyline points="9 10 4 15 9 20"></polyline><path d="M20 4v7a4 4 0 0 1-4 4H4"></path></svg>
    </button>
    </div>
    <ul class="slides">

    ${ this._slides && this._slides.length
    ? this._slides.map(
    (x) => `
    <li>
    ${ x.url
    ? `<a target="_blank" href="${x.url}"> <img data-src="${x.imageUrl}" class="lazyload" /> ${ x.text ? `<p class="flex-caption">${x.text}</p>` : "" } </a>`
    : `<img data-src="${x.imageUrl}" class="lazyload" /> ${ x.text ? `<p class="flex-caption">${x.text}</p>` : "" }`
    }
    </li>
    `
    )
    : ""
    }
    </ul>
    </div>
    </div>
    `);

    // Bind flexslider for preview
    editor.insertAdjacentElement("beforebegin", html.flexslider);

    let animation = document.querySelector(".effect").value;

    if (animation === "8") {
    animation = "fade";
    } else if (animation === "1") {
    animation = "slide";
    }

    const opts = {
    useCSS: true,
    touch: true,
    animation: animation,
    animationSpeed: document.querySelector(".speed").value,
    slideshowSpeed: document.querySelector(".duration").value,
    // startAt: parseInt(document.querySelector(".start").value),
    directionNav: document.querySelector("#arrows").checked,
    prevText: "<",
    nextText: ">",
    pauseOnHover: document.querySelector("#pause").checked,
    randomize: document.querySelector("#random").checked,
    start: (e) => {
    window.flexslider = e;
    log.debug("START")
    },
    };

    $("#slider1").flexslider(opts);

    editor.style.display = "none";

    // Preview events
    this._on(html.back, "click", (e) => {
    log.debug("BACK:START", this._slide)

    editor.style.display = "block";

    $("#slider1").remove();
    _.rm(html.flexslider)

    log.debug("BACK:END", this._slide)
    });

    tippy(html.back, {
    content: "Back",
    delay: 350
    });

    // Send event
    log.debug("PREVIEW:END", $("#slider1"))
    }

    spinner = {
    start: () => {
    const { editorSpinner } = this._root.editorActions
    editorSpinner.style.opacity="1";
    editorSpinner.style.zIndex="1000";
    },

    stop: () => {
    const { editorSpinner } = this._root.editorActions
    editorSpinner.style.opacity="0";
    editorSpinner.style.zIndex="0";
    }
    }

    async _done(e) {
    log.debug("DONE:START", this._activeSlide())

    const { zoomerWrap, overlay, boundary } = this._slide.elements;
    const { _slideInitIndex, _slides } = this;
    const { editorActions } = this._root;
    const { storeId, publishPosition } = this.options;

    this._slide.elements.img.classList.add('sleep')
    this.spinner.start()
    zoomerWrap.style.zIndex="0";

    // Save edited photo
    const blob = await this._slide.result({ type: "blob" });
    const url = window.URL.createObjectURL(blob);
    _slides[_slideInitIndex].imageUrl = url;

    // Hide loader
    await new Promise(r => setTimeout(r, Math.floor(Math.random()*700+400)))

    zoomerWrap.style.zIndex="100";
    this.spinner.stop()

    await new Promise(r => setTimeout(r, 50))

    this._slide.elements.img.classList.remove('sleep')
    this._hideCropActions()
    this._showEditorActions()

    log.debug("DONE:END", this._activeSlide())
    }

    async _replace(e){
    log.debug("REPLACE:START", e)

    if (e.target.files?.length === 0) return;

    const { left, right, selectContainer, edit, rm, preview, URL, caption } = this._root.editorActions

    // Loader
    this._slide.elements.img.classList.add('sleep')
    this.spinner.start()

    // Bind replacing image to the Editor
    const file = e.target.files[0];
    const url = window.URL.createObjectURL(file);

    // less than 1mb? wait a bit
    if (file.size < 1000000) {
    await new Promise(r => setTimeout(r, Math.floor(Math.random()*500+300)))
    }

    await this._slide.bind(url)

    // Enable editor actions
    if (this._isDefaultImage()) {
    [edit, preview, rm, URL, caption, left, right, selectContainer].forEach(x => x.classList.remove("disabled"))
    }

    // Crop image and save it
    const blob = await this._slide.result({ type: "blob" });
    const img = window.URL.createObjectURL(blob);
    this._slides[this._slideInitIndex].imageUrl = img;

    // Hide loader
    this._slide.elements.img.classList.remove('sleep')
    this.spinner.stop()

    log.debug("REPLACE:END", this._activeSlide())
    }

    async _publish(e) {
    log.debug("PUBLISH:START", this);

    const { storeId } = this.options
    const { publish, publishSpinner, publishText } = this._root.editorActions

    publish.classList.remove("active")
    publish.classList.add("active");
    publishSpinner.style.display="block"
    publishText.style.opacity="0"


    try {
    // Get urls from blobs
    const { err, slides } = await (async () => {
    let slides = []
    let err

    for(const slide of this._slides) {
    const { imageUrl } = slide

    // Ignore the default image
    if (imageUrl.indexOf('plain-white-background') > -1) {
    continue;
    }

    if (imageUrl && imageUrl.startsWith("blob")) {
    const blob = await fetch(imageUrl).then(r => r.blob())
    const url = await this.url(blob, storeId).catch(err => {
    acc.err += err
    })

    if (url) {
    slide.imageUrl = url
    }
    }

    slides.push(slide)
    }

    return { err, slides }
    })()

    if (err) {
    throw new Error(err)
    }

    const set = this._saveSettings()
    const cp = _.shallow(set)
    const parsed = _.parse(cp)
    const slide = _.assign(parsed, {slides})


    // Save slides
    let request = {
    uri: "Editor/svc/Services.svc/UpdateSlideShow",
    method: "POST",
    body: _.str({
    storeId: storeId,
    debugId: log._uuid,
    slide: _.str(slide)
    }),
    headers: {
    "content-type": "application/json",
    },
    };

    await fetch(request.uri, request).then((response) =>
    handleResponse({
    request,
    response
    })).then(() => {
    // Success!
    Swal.fire({
    icon: "success",
    animation: false,
    title: "Your work has been saved",
    showConfirmButton: false,
    timer: 1500,
    });
    }).catch((err) => {
    Swal.fire({
    icon: "error",
    title: "Oops...",
    html: err,
    animation: false,
    confirmButtonColor: "#f054a7",
    });

    log.error("Failed to save SlideShow. Request: " + _.str(request) + ". Error: " + err);
    }).then(async () => {
    // Stop spinner
    publish.classList.remove("active");
    publishSpinner.style.display="none"
    publishText.style.opacity="1"

    log.debug("PUBLISH:END", this);
    })
    } catch (err) {
    Swal.fire({
    icon: "error",
    title: "Oops...",
    html: err,
    animation: false,
    confirmButtonColor: "#f054a7",
    });

    log.error("Something went wrong prepparing the slideshow for save. Error:", err);

    publish.classList.remove("active");
    publishSpinner.style.display="none"
    publishText.style.opacity="1"
    }
    }

    activeSlideIndex() {
    let { select } = this._root.editorActions;

    select = Array.from(select).filter(Boolean);

    const active = select.findIndex((x) => x.classList.contains("active"));

    return active;
    }

    async _buildImageEditorShell() {
    const { slideshowTarget } = this.options;
    const { _slideInitIndex, namespace } = this;
    const show = this.options?.visible
    const slideSettings = this.options;

    // Destory any previous croppie as we can only init one at a time
    if (this._slide) {
    this._slide.destroy();
    this._slide = null;
    log.debug("Destoryed croppie instance");
    }

    // Build slide editor
    // Default image

    // TODO: resolveOptions should set the this.startImageUrl so we are not doing checks like this
    let url = this._activeSlide().imageUrl
    if (!url) {
    url = "https://d13z1xw8270sfc.cloudfront.net/origin/477866/plain-white-background_1592919904955.jpg";
    }

    var { offsetWidth } = slideshowTarget.parentElement;
    const { width, height } = await _.dimen(url);
    const aspect = _.aspect(width, height, offsetWidth, offsetWidth);

    const opt = {
    width: aspect.width,
    height: aspect.height,
    target: slideshowTarget,
    url,
    mouseWheelZoom: false,
    disabled: !show
    };

    log.debug("Building croppie instance with options: ", opt);
    this._slide = await this._buildCroppieInstance(opt);
    log.debug("Built croppie instance with options: ", opt);

    const { zoomerWrap, overlay, boundary } = this._slide.elements;

    // Disable interaction
    zoomerWrap.style.opacity = "0";
    overlay.style.pointerEvents = "none";

    log.debug(`Building editor html`)

    // Paint shell actions over editor
    const html = _.createFromTemplate(`
    <div :ref=slideEditorShellContainer style="width:${
    boundary.offsetWidthh
    }px; height:${
    boundary.offsetHeight
    }px" class="${namespace}-slide-editor-shell-container">
    <div class="${namespace}-editor-toolbar" :ref=container>

    <!-- SPINER -->
    <div :ref=editorSpinner style="top:50%;left:50%;z-index:0;" class="spinner">
    <span style="border-right-color:black;"></span>
    </div>
    <!-- SPINER -->

    <!-- CROP ACTIONS -->
    <div style="top:2.25em; visibility:hidden; opacity:0;" :ref=cropActions class="btn-group shell-actions">
    <button :ref=cancel class="btn">
    <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-x"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
    </button>

    <button :ref=skip class="btn">
    <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-skip-forward"><polygon points="5 4 15 12 5 20 5 4"></polygon><line x1="19" y1="5" x2="19" y2="19"></line></svg>
    </button>

    <button :ref=done class="btn">
    <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-check"><polyline points="20 6 9 17 4 12"></polyline></svg>
    </button>
    </div>
    <!-- CROP ACTIONS -->

    <div :ref=toolbarContainer class="btn-group shell-actions">
    <!-- Add -->
    <button :ref=add class="btn">
    <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-plus"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
    </button>
    <input :ref=addInput type="file" class="shell-input" style="display:none;">
    <!-- Add -->

    <div class="de-toolbar-divide"></div>

    <!-- Crop -->
    <button :ref=edit class="btn ${this._isDefaultImage() ? "disabled": ""}">
    <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-crop"><path d="M6.13 1L6 16a2 2 0 0 0 2 2h15"></path><path d="M1 6.13L16 6a2 2 0 0 1 2 2v15"></path></svg>
    </button>
    <!-- Crop -->

    <!-- Order slides -->
    <div :ref=moveContainer class="btn-group de-order-slides">

    <!-- Left -->
    <div :ref=left class="btn ${this._isDefaultImage() || this._slides.length === 1 ? "disabled" : ""}">
    <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-arrow-left"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>
    </div>
    <!-- Left -->

    <!-- Right -->
    <div :ref=right class="btn ${this._isDefaultImage() || this._slides.length === 1 ? "disabled" : ""}">
    <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-arrow-right"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>
    </div>
    <!-- Right -->

    </div>
    <!-- Order slides -->

    <!-- URL -->
    <button :ref=URL class="btn ${this._isDefaultImage() ? "disabled": ""}">
    <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link-2"><path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"></path><line x1="8" y1="12" x2="16" y2="12"></line></svg>
    </button>
    <!-- URL -->

    <!-- Caption -->
    <button :ref=caption class="btn ${this._isDefaultImage() ? "disabled": ""}">
    <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-type"><polyline points="4 7 4 4 20 4 20 7"></polyline><line x1="9" y1="20" x2="15" y2="20"></line><line x1="12" y1="4" x2="12" y2="20"></line></svg>
    </button>

    <!-- Delete -->
    <button :ref=rm class="btn ${this._isDefaultImage() ? "disabled": ""}">
    <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-trash"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
    </button>
    <!-- Delete -->

    </div>
    <!-- shell actions -->

    <!-- Select Actions -->
    <div :ref=selectMoveContainer class="select-actions">

    <!-- Select -->
    <div :ref=selectContainer class="btn-group select-container ${this._isDefaultImage() || this._slides.length === 1 ? "disabled" : ""}">

    ${
    this._slides && this._slides.length
    ? this._slides
    .map((slide) =>
    `<div :arr=select class="select-item"></div>`
    )
    .join("")
    : ""
    }
    </div>
    <!-- Select -->


    </div>
    <!-- Select Actions -->


    <!-- settings -->
    <div :ref=settingsPanel class='slideshow-settings-panel'>
    <div class='setting'>
    <div class='setting-header'>
    <svg :ref=visibleHelp cmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#aaa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-help-circle"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>
    <div class='setting-title'>Visible</div>
    </div>
    <div class='setting-action'>
    <input type="checkbox" :ref=visible id="visible" name="set-name" class="switch-input">
    <label for="visible" class="switch-label"></label>
    </div>
    </div>

    <div class='divide'></div>

    <div class='setting'>
    <div class='setting-header'>
    <svg :ref=effectHelp cmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#aaa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-help-circle"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>
    <div class='setting-title'>Effect</div>
    </div>
    <div class='setting-action'>
    <select :ref=effect class="setting-select effect">
    <option value="1">Slide</option>
    <option value="8">Fade</option>
    </select>
    </div>
    </div>

    <div class='setting'>
    <div class='setting-header'>
    <svg :ref=speedHelp cmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#aaa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-help-circle"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>
    <div class='setting-title'>Speed</div>
    </div>
    <div class='setting-action'>
    <select :ref=speed class="setting-select speed">
    <option value="0">Manual</option>
    <option value="1800">Very Slow</option>
    <option value="1200">Slow</option>
    <option value="800">Fast</option>
    <option value="300">Very Fast</option>
    </select>
    </div>
    </div>

    <div class='setting'>
    <div class='setting-header'>
    <svg :ref=durationHelp cmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#aaa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-help-circle"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>
    <div class='setting-title'>Duration</div>
    </div>
    <div class='setting-action'>
    <select :ref=duration class="setting-select duration">
    <option value="3000">Very Short</option>
    <option value="5000">Short</option>
    <option value="7000">Long</option>
    <option value="10000">Very Long</option>
    </select>
    </div>
    </div>

    <div class='divide'></div>

    <div class='setting'>
    <div class='setting-header'>
    <svg :ref=randomHelp cmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#aaa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-help-circle"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>
    <div class='setting-title'>Random</div>
    </div>
    <div class='setting-action'>
    <input type="checkbox" :ref=random id="random" name="set-name" class="switch-input">
    <label for="random" class="switch-label"></label>
    </div>
    </div>

    <div class='setting'>
    <div class='setting-header'>
    <svg :ref=hoverPauseHelp cmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#aaa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-help-circle"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>
    <div class='setting-title'>Hover pause</div>
    </div>
    <div class='setting-action'>
    <input type="checkbox" :ref=pause id="pause" name="set-name" class="switch-input">
    <label for="pause" class="switch-label"></label>
    </div>
    </div>

    <div class='setting'>
    <div class='setting-header'>
    <svg :ref=arrowsHelp cmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#aaa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-help-circle"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>
    <div class='setting-title'>Arrows</div>
    </div>
    <div class='setting-action'>
    <input type="checkbox" :ref=arrows id="arrows" name="set-name" class="switch-input">
    <label for="arrows" class="switch-label"></label>
    </div>
    </div>
    </div>

    <div :ref=settingsContainer class="btn-group settings">
    <button :ref=settings class="btn">
    <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-settings"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
    </button>
    </div>
    <!-- settings -->

    <input :ref=replace type="file" class="shell-input">

    <!-- PUBLISH/PREVIEW ACTIONS -->
    <div style="position: absolute; bottom:1em; right:1em; display:flex;">
    <!-- PREVIEW -->
    <div :ref=preview style="color:#717171; background:#dcdcdc;" class="${this._isDefaultImage() ? "disabled" : ""} de-widget-item de-publish">
    <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-eye"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>
    <p :ref=previewText style="padding-left:8px; font-size:14px;" class="de-widget-item-text">Preview</p>
    </div>
    <!-- PREVIEW -->

    <!-- PUBLISH -->
    <div style="margin-left: 1em;" :ref=publish class="de-widget-item de-publish">
    <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-save"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
    <p :ref=publishText style="padding-left:8px; font-size:14px;" class="de-widget-item-text">Save</p>
    <!-- SPINER -->
    <div :ref=publishSpinner style="top:8px; width:100px; position:absolute; transform: scale(0.4, 0.4); width: 100px; display:none" class="loader-inner ball-pulse">
    <div></div>
    <div></div>
    <div></div>
    </div>
    <!-- SPINER -->
    </div>
    <!-- PUBLISH -->
    <div>
    <!-- PUBLISH/PREVIEW ACTIONS -->

    </div>
    `);

    const {
    slideEditorShellContainer,
    addInput,
    add,
    edit,
    replace,
    rm,
    settingsContainer,
    preview,
    duration,
    start,
    random,
    pause,
    speed,
    arrows,
    effect,
    visible,
    URL,
    caption,
    left,
    right,
    done,
    cancel,
    select,
    skip
    } = html;

    boundary.appendChild(slideEditorShellContainer);
    log.debug(`Built editor html`)

    // Set slideshowsetiings
    log.debug(`Setting slideshow setting`)
    duration.value = slideSettings.pauseTime;
    // start.value = slideSettings.startSlide;
    random.checked = slideSettings.randomStart;
    pause.checked = slideSettings.pauseOnHover;
    speed.value = slideSettings.animSpeed;
    arrows.checked = slideSettings.nav;
    effect.value = slideSettings.effect;
    visible.checked = slideSettings.visible;
    log.debug(`Set slideshow setting`)

    // Save in memory
    this._root.editorActions = Object.assign(this._root.editorActions, html);

    this._root.editorActions.select[_slideInitIndex].classList.add("active");

    // Tooltips
    const toolbarTips = [
    tippy(URL, {
    content: "URL",
    }),

    tippy(caption, {
    content: "Caption",
    }),

    tippy(rm, {
    content: "Delete",
    }),

    tippy(edit, {
    content: "Crop",
    }),

    tippy(add, {
    content: "Add",
    }),

    tippy(right, {
    content: "Move Right",
    }),

    tippy(left, {
    content: "Move Left",
    }),
    ]

    const settingsTips = [
    tippy(html.durationHelp, {
    content:
    "This changes the length of time the slide shows for, before automatically moving to the next slide.",
    theme: "light-border",
    }),

    tippy(html.speedHelp, {
    content:
    "This setting changes the length of time the effect takes when moving between slides.",
    theme: "light-border",
    }),

    tippy(html.effectHelp, {
    content:
    "This setting changes the effect used when moving between slides.",
    theme: "light-border",
    }),

    tippy(html.visibleHelp, {
    content: "This controls whether to show the Slideshow on your store.",
    theme: "light-border",
    }),

    tippy(html.arrowsHelp, {
    content:
    "Checking this option will allow the customer to manually move back and forth through the slideshow if they wish. Unchecking it will remove that navigation. In either case, the slideshow will still automatically progress according to your other settings.",
    theme: "light-border",
    }),

    tippy(html.hoverPauseHelp, {
    content:
    "This will pause the slideshow when the customer hovers over the image, and continue once they move their mouse off. A pointer hovering over a slide usually indicates interest in that slide, and the customer wants or needs more time to look at it.",
    theme: "light-border",
    }),

    tippy(html.randomHelp, {
    content:
    "Checking this will always start the slideshow on a random slide when first loaded. The slideshow will continue from that point, and loop around to the beginning once it reaches the end.",
    theme: "light-border",
    }),

    ]

    tippy(settingsContainer, {
    content: "Settings",
    delay: 350,
    })

    const cropActionTips = [
    tippy(done, {
    content: "Crop Image",
    delay: 350,
    }),

    tippy(skip, {
    content: "Skip Crop",
    delay: 350,
    }),

    tippy(cancel, {
    content: "Cancel",
    delay: 350,
    })
    ]

    createSingleton(cropActionTips, {
    delay: 350,
    moveTransition: 'transform 0.2s ease-out',
    updateDuration: 200,
    })

    createSingleton(toolbarTips, {
    delay: 350,
    moveTransition: 'transform 0.2s ease-out',
    updateDuration: 200,
    })

    createSingleton(settingsTips, {
    delay: 350,
    moveTransition: 'transform 0.2s ease-out',
    updateDuration: 200,
    })

    // Events
    this._on(html.publish, "click", (e) => this._publish(e));
    this._on(html.skip, "click", (e) => this._skip(e))
    this._on(cancel, "click", async (e) => this._cancel(e));
    this._on(done, "click", async (e) => this._done(e));
    this._on(preview, "click", async (e) => this._preview(e));
    this._on(replace, "change", async (e) => this._replace(e));
    this._on(rm, "click", (e) => this._delete(e));

    this._on(
    [
    duration,
    random,
    pause,
    speed,
    arrows,
    effect,
    visible,
    ], "click", (e) => {
    if (e.target === visible) {
    if (visible.checked) {
    this._slide.elements.img.classList.remove('disabled')
    } else {
    this._slide.elements.img.classList.add('disabled')
    }
    }

    this._saveSettings()
    })


    this._on(URL, "click", (e) => {
    const { captionInput, urlInput } = this._root.editorActions

    if (URL.classList.contains("disabled")) return

    if (captionInput) {
    this._saveCaptionText()
    _.rm(captionInput)
    this._root.editorActions.captionInput = null
    }

    if (urlInput) {
    this._saveUrlText()
    _.rm(urlInput)
    this._root.editorActions.urlInput = null
    return
    }

    // Get position
    let { top, left, height, width} = URL.getBoundingClientRect()
    const urlWidth = 170
    top = top + height + 5 + window.scrollY
    left = left - (urlWidth / 2) + 20

    // Add HTML Component
    const html = _.createFromTemplate(`
    <div style="width:${urlWidth}px; left:${left}px; top:${top}px" :ref=urlInput class="de-input de-url">
    <input :ref=urlInputField style="width:${urlWidth}px;" ${this._activeSlide().url ? `value=${this._activeSlide().url}` : 'placeholder="https://google.com"'}>
    `)

    Object.assign(this._root.editorActions, html)
    _.inAfter(this._root.editorActions.urlInput, document.body)

    // Focus cursor
    html.urlInputField.focus()

    // close URL input if its active
    if (this._root.editorActions.urlInput.classList.contains("active")) {
    this._root.editorActions.urlInput.classList.remove("active");
    }
    });

    this._on(caption, "click", (e) => {
    const { urlInput, captionInput } = this._root.editorActions

    if (caption.classList.contains("disabled")) return

    if (urlInput) {
    this._saveUrlText()
    _.rm(urlInput)
    this._root.editorActions.urlInput = null
    }

    if (captionInput) {
    this._saveCaptionText()
    _.rm(captionInput)
    this._root.editorActions.captionInput = null
    return
    }

    // Get position
    let { top, left, height, width} = caption.getBoundingClientRect()
    const captionWidth = 170
    top = top + height + 5 + window.scrollY
    left = left - (captionWidth / 2) + 20

    // Add HTML Component
    const html = _.createFromTemplate(`
    <div style="width:${captionWidth}px; left:${left}px; top:${top}px" :ref=captionInput class="active de-input de-caption">
    <input :ref=captionInputField style="width:${captionWidth}px;" ${this._activeSlide().text ? `value=${this._activeSlide().text}` : 'placeholder="Your Caption Here"'}>
    `)

    Object.assign(this._root.editorActions, html)
    _.inAfter(this._root.editorActions.captionInput, document.body)

    // Focus cursor
    html.captionInputField.focus()

    // close URL input if its active
    if (this._root.editorActions.captionInput.classList.contains("active")) {
    this._root.editorActions.captionInput.classList.remove("active");
    }
    });


    this._on(left, "click", (e) => {
    log.debug("MOVE:START", this._slides)

    const { select } = this._root.editorActions;

    // Get index of selected image
    const activeIndex = this.activeSlideIndex();

    const newIndex = activeIndex - 1;

    // Prevent clicking too far left
    if (newIndex === -1) return;

    // Update init index
    this._slideInitIndex = newIndex;

    // Give active class to selected img
    select.forEach((s) => s.classList.remove("active"));
    select[newIndex].classList.add("active");

    // Set the order on the image
    select.forEach((s, i) => this._slides[i].sequence);

    // Reorder slides
    _.arrayMove(this._slides, activeIndex, newIndex);

    // Reindex slide sequence
    this._slides.forEach((s, i) => {
    // Index starts @ 1 :(
    s.sequence = i + 1;
    });

    log.debug("MOVE:END", this._slides)
    });

    this._on(right, "click", (e) => {
    log.debug("MOVE:START", this._slides)

    const { select } = this._root.editorActions;

    // Get index of selected image
    const activeIndex = this.activeSlideIndex();

    const newIndex = activeIndex + 1;

    // Prevent clicking too far right
    if (newIndex === select.length) return;

    // Update init index
    this._slideInitIndex = newIndex;

    // Give active class to selected img
    select.forEach((s) => s.classList.remove("active"));
    select[newIndex].classList.add("active");

    // Set the order on the image
    select.forEach((s, i) => this._slides[i].sequence);

    // Reorder slides
    _.arrayMove(this._slides, activeIndex, newIndex);

    // Reindex slide sequence
    this._slides.forEach((s, i) => {
    // Index starts @ 1 :(
    s.sequence = i + 1;
    });

    log.debug("MOVE:END", this._slides)
    });

    this._on(settingsContainer, "click", (e) => {
    const el = document.querySelector(".slideshow-settings-panel");

    if (el.style.display === "none" || el.style.display === "") {
    el.style.display = "block";
    } else {
    el.style.display = "none";
    }
    });



    this._on(add, "click", (e) => addInput.click());

    // https://stackoverflow.com/a/12102992/2263032
    this._on(addInput, "click", function(){
    this.value = null;
    })

    this._on(addInput, "change", async (e) => this._addSlide(e));

    this._on(edit, "click", async (e) => this._edit(e));

    this._on(select, "click", async (e) => this._select(e));
    }

    _saveCaptionText(){
    const { captionInput } = this._root.editorActions

    if (!captionInput) return

    const activeIndex = this.activeSlideIndex();
    const caption = captionInput.querySelector(
    "input"
    ).value;
    this._slides[activeIndex].text = _.slug(caption)
    }

    // Save url text
    _saveUrlText(){
    const { urlInput } = this._root.editorActions

    if (!urlInput) return

    const activeIndex = this.activeSlideIndex();
    const url = urlInput.querySelector(
    "input"
    ).value;

    this._slides[activeIndex].url = _.slug(url)
    }

    _delete(e) {
    log.debug("DELETE:START", this._slides);

    if (this._root.editorActions.rm.classList.contains("disabled")) return

    return Swal.fire({
    animation: false,
    title: "Are you sure?",
    text: "You won't be able to revert this!",
    icon: "warning",
    showCancelButton: true,
    confirmButtonColor: "#20b7e6",
    cancelButtonColor: "#ea1636",
    confirmButtonText: "Yes, delete it!",
    }).then((result) => {
    if (!result.value) return

    const { edit, preview, rm, URL, left, right, selectContainer, caption } = this._root.editorActions

    const oldIndex = this._root.editorActions.select.findIndex((x) =>
    x.classList.contains("active")
    );

    let newIndex = 0;

    // Use default image if _slides have been deleted
    if (this._slides.length === 1) {
    this._slides[oldIndex].imageUrl = "https://d13z1xw8270sfc.cloudfront.net/origin/477866/plain-white-background_1592919904955.jpg";

    // Disable editor actions
    [left, right, selectContainer, edit, preview, rm, URL, caption].forEach(x => x.classList.add("disabled"))
    }
    // Remove from
    // - slides array
    // - select array
    // - select html
    else {
    _.rm(this._root.editorActions.select[oldIndex]);
    this._slides.splice(oldIndex, 1);
    this._root.editorActions.select.splice(oldIndex, 1);

    // Move SELECT gallery left one place
    //
    // FROM
    // x - x - o - x
    // ^
    // '
    //
    // TO
    // x - o - x - x
    // ^
    // '
    if (oldIndex > 0) {
    newIndex = oldIndex - 1;
    }
    }

    if (this._slides.length < 2) {
    [left, right, selectContainer].forEach(x => x.classList.add("disabled"))
    }

    this._root.editorActions.select[newIndex].classList.add("active");

    const url = this._slides[newIndex].imageUrl;
    this._slide.bind({ url });

    this._slideInitIndex = newIndex;

    log.debug("DELETE:END", this._slides);
    });
    }

    async _edit(e) {
    log.debug("EDIT:START", this._slide)

    const { preview, edit, selectMoveContainer, captionInput, editorSpinner, urlInput, publish, settingsPanel, cropActions, toolbarContainer, moveContainer, selectContainer, settingsContainer, replace } = this._root.editorActions
    const { zoomerWrap, overlay, boundary } = this._slide.elements;

    if (edit.classList.contains("disabled")) return

    // Loader
    this._slide.elements.img.classList.add('sleep')

    // Save points incase the user decides to cancel
    this._slide.backup = this._slide.get()

    if (captionInput) {
    // Save text
    const activeIndex = this.activeSlideIndex();
    this._slides[activeIndex].text = this._root.editorActions.captionInput.querySelector(
    "input"
    ).value;

    _.rm(captionInput)
    this._root.editorActions.captionInput = null
    }

    if (urlInput) {
    // Save text
    const activeIndex = this.activeSlideIndex();
    this._slides[activeIndex].url = this._root.editorActions.urlInput.querySelector(
    "input"
    ).value;

    _.rm(urlInput)
    this._root.editorActions.urlInput = null
    }

    this._hideEditorActions()

    this.spinner.start()

    // Wait between 0.6s-1.0s
    await new Promise(r => setTimeout(r, Math.floor(Math.random()*1000+600)))

    this._slide.elements.img.classList.remove('sleep')

    // Enable interaction
    zoomerWrap.style.opacity = "1";
    zoomerWrap.style.display = "block";
    overlay.style.pointerEvents = "auto";

    this.spinner.stop()

    // Show DONE and CANCEL
    cropActions.style.opacity="1"
    cropActions.style.zIndex="100"
    cropActions.style.visibility="visible"

    log.debug("EDIT:END", this._slide)
    }

    async _select(e){
    log.debug("SELECT:START", this._slides[newIndex]);

    const { urlInput, captionInput, } = this._root.editorActions

    if (captionInput) {
    // Save text
    const activeIndex = this.activeSlideIndex();
    this._slides[activeIndex].text = this._root.editorActions.captionInput.querySelector(
    "input"
    ).value;

    _.rm(captionInput)
    this._root.editorActions.captionInput = null
    }

    if (urlInput) {
    // Save text
    const activeIndex = this.activeSlideIndex();
    this._slides[activeIndex].url = this._root.editorActions.urlInput.querySelector(
    "input"
    ).value;

    _.rm(urlInput)
    this._root.editorActions.urlInput = null
    }

    // Get index of selected image
    var newIndex = Array.from(this._root.editorActions.select).indexOf(
    e.target
    );
    this._slideInitIndex = newIndex;

    // Give active class to selected img
    this._root.editorActions.select.forEach((s) =>
    s.classList.remove("active")
    );
    this._root.editorActions.select[newIndex].classList.add("active");

    // Bind image to editor
    const url = this._slides[newIndex].imageUrl;
    await this._slide.bind({ url });

    log.debug("SELECT:END", this._slides[newIndex]);
    }

    _saveSettings() {
    const { duration, start, random, pause, speed, arrows, effect, visible } = this._root.editorActions

    log.debug("SETTINGS:START", this.options)

    Object.assign(this.options, {
    animSpeed: speed.value,
    effect: effect.value,
    nav: arrows.checked,
    pauseOnHover: pause.checked,
    pauseTime: duration.value,
    randomStart: random.checked,
    // startSlide: start.value ? start.value : 0,
    visible: visible.checked ? 1 : 0,
    });


    log.debug("SETTINGS:END", this.options)

    return this.options
    }

    _onError(err) {
    this._emit("error", err)
    }

    /**
    * Add event(s) to element(s).
    * @param elements DOM-Elements
    * @param events Event names
    * @param fn Callback
    * @param options Optional options
    * @return Array passed arguments
    */
    _on = this._listen.bind(this, "addEventListener");

    _listen(method, elements, events, fn, options = {}) {
    // Normalize array
    if (elements instanceof HTMLCollection || elements instanceof NodeList) {
    elements = Array.from(elements);
    } else if (!Array.isArray(elements)) {
    elements = [elements];
    }

    if (!Array.isArray(events)) {
    events = [events];
    }

    for (const el of elements) {
    for (const ev of events) {
    el[method](ev, this._handleError(fn), { capture: false, ...options });
    }
    }

    return Array.prototype.slice.call(arguments, 1);
    }

    _handleError(func) {
    const that = this

    return function() {
    try {
    Promise.resolve(
    func.apply(this, arguments)
    ).catch(err =>{
    that._emit("error", err)
    })
    } catch(err) {
    that._emit("error", err)
    }
    }
    }

    _isDefaultImage() {
    return this._slides && this._slides.length && this._slides[0].imageUrl.indexOf('plain-white-background') > -1
    }

    async _addSlide(e) {
    log.debug("ADD:START", e)

    if (e.target.files?.length === 0) return;
    const file = e.target.files[0]
    e = null

    const { left, right, urlInput, captionInput, start, select, selectContainer, edit, rm, preview, URL, caption } = this._root.editorActions

    // Loader
    this._slide.elements.img.classList.add('sleep')
    this.spinner.start()

    // Save other image meta data
    if (captionInput) {
    this._saveCaptionText()
    _.rm(captionInput)
    this._root.editorActions.captionInput = null
    }
    if (urlInput) {
    // Save text
    const activeIndex = this.activeSlideIndex();
    this._slides[activeIndex].url = this._root.editorActions.urlInput.querySelector(
    "input"
    ).value;

    _.rm(urlInput)
    this._root.editorActions.urlInput = null
    }

    // Slide limit
    if (this._slides.length >= this._slideLimit) {
    this._emit("error", `Reached slide limit of ${this._slideLimit}!`);
    return;
    }

    const url = window.URL.createObjectURL(file);

    // ONLY add slide if its NOT the DEFAULT image
    if (!this._isDefaultImage()) {
    // Add gallery item to SELECT
    const html = _.createFromTemplate(`<div :ref=select class="select-item"></div>`)
    _.inAfter(html.select, selectContainer)
    this._on(html.select, "click", async (e) => this._select(e));
    let i = select.push(html.select)

    this._slides.push({
    imageUrl: url,
    });

    // Set next active slide on SELECT html
    const active = this.activeSlideIndex()
    const next = active + 1
    select[active].classList.remove('active')
    select[next].classList.add('active')

    // Move slide next to the active one in _slides
    const last = this._slides.length - 1
    const ls = this._slides
    _.arrayMove(ls, last, next)
    this._slideInitIndex = next;
    } else {
    // Enable editor actions
    [edit, preview, rm, URL, caption, left, right, selectContainer].forEach(x => x.classList.remove("disabled"))
    }

    if (this._slides.length > 1) {
    [selectContainer, left, right].forEach(x => x.classList.remove("disabled"))
    }

    // less than 1mb? wait a bit
    if (file.size < 1000000) {
    await new Promise(r => setTimeout(r, Math.floor(Math.random()*500+300)))
    }

    // Bind image to editor
    await this._slide.bind(url);

    // Save edited photo
    const blob = await this._slide.result({ type: "blob" });
    const img = window.URL.createObjectURL(blob);
    this._slides[this._slideInitIndex].imageUrl = img;

    // Hide loader
    this._slide.elements.img.classList.remove('sleep')
    this.spinner.stop()

    log.debug("ADD:END", this._activeSlide())
    }

    _destroyImageEditor() {
    Object.keys(this._root.editorActions).forEach((key) => {
    if (Array.isArray(this._root.editorActions[key])) {
    this._root.editorActions[key].forEach((el, i) => {
    _.rm(this._root.editorActions[key][i]);
    delete this._root.editorActions[key][i];
    });

    // reindex array
    this._root.editorActions[key] = Array.from(
    this._root.editorActions[key]
    ).filter(Boolean);

    delete this._root.editorActions[key];
    } else {
    _.rm(this._root.editorActions[key]);
    delete this._root.editorActions[key];
    }
    });

    this._root.editorActions = {};
    }

    async _buildImageEditor() {
    const { namespace, _slideInitIndex, } = this;
    const { slideshowTarget } = this.options

    // Destory any previous croppie as we can only init one at a time
    if (this._slide) {
    this._slide.destroy();
    this._slide = null;
    }

    // Build Croppie image dimentions
    const url = this._slides[_slideInitIndex].imageUrl;
    var { offsetWidth } = slideshowTarget.parentElement;
    const { width, height } = await _.dimen(url);
    const aspect = _.aspect(width, height, offsetWidth, offsetWidth);
    const opt = {
    width: aspect.width,
    height: aspect.height,
    target: slideshowTarget,
    url,
    };

    this._slide = await this._buildCroppieInstance(opt);

    var el = _.createFromTemplate(`
    <div :obj="editorActions">

    <div class="${namespace}-editor-toolbar" :ref=container>

    <!-- DONE -->
    <div class="done" :ref=done>
    <button :ref=done class="${namespace}-toolbar-actions-item">
    <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-check"><polyline points="20 6 9 17 4 12"></polyline></svg>
    </button>
    </div>
    <!-- DONE -->


    <!-- MODIFY -->
    <div :ref=modify class="modify">
    <!-- REPLACE -->
    <button :ref=cancel class="${namespace}-toolbar-actions-item">
    <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-x"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
    </button>
    <input :ref="input" type="file" style="display:none;"/>
    <!-- REPLACE -->
    </div>
    <!-- MODIFY -->

    </div>
    </div>
    `);

    // Ref
    this._root.editorActions = Object.assign(
    this._root.editorActions,
    el.editorActions
    );

    const { done, cancel } = this._root.editorActions;

    // Tooltips
    tippy(done, {
    content: "Done",
    delay: 350,
    });
    tippy(cancel, {
    content: "Cancel",
    delay: 350
    });

    // Events
    this._on(cancel, "click", async (e) => this._cancel(e));
    this._on(done, "click", async (e) => this._done(e));

    slideshowTarget.appendChild(el.editorActions.container);
    }

    _buildStyles() {
    var { selectedColours, css, el } = this._style;

    if (!selectedColours) return;

    // Compile new style sheet
    var compiled_style;

    selectedColours.forEach(function (clr) {
    var regex = new RegExp("\\[" + clr.name + "\\]", "g");

    var hex = clr.hex.trim();

    if (hex.startsWith("#")) {
    hex = hex.slice(1);
    }

    if (compiled_style) {
    compiled_style = compiled_style.replace(regex, hex);
    } else {
    compiled_style = css.replace(regex, hex);
    }
    });

    // Append the compiled style sheet
    var css = document.querySelector(el);

    if (!css) {
    _.addStyle(el, compiled_style);
    } else {
    _.rm(css);
    _.addStyle(el, compiled_style);
    }
    }

    _buildColourPicker(e) {
    if (this._pickr) {
    this._pickr.destroyAndRemove();
    }

    // Create the colour pickr
    var pickr = new Pickr({
    el: e.target,
    theme: "nano",
    padding: 10,
    default: this._style.initColour,
    useAsButton: true,
    // showAlways: true,
    autoReposition: false,
    components: {
    preview: true,
    opacity: true,
    hue: true,
    },
    });

    pickr.show();

    // pickr._root.app.style.display = 'none'

    // // Reposition the pickr to where the click occured.
    // var { top, left } = this._style.rectangle

    // top = top + 30
    // left = left - 93
    // setTimeout(function(){
    // var arrow = document.createElement('div')
    // arrow.className = 'pcr-tooltip-arrow'
    // pickr._root.app.appendChild(arrow)

    // pickr._root.app.style.top = top.toFixed(3) + 'px'
    // pickr._root.app.style.left = left.toFixed(3) + 'px'

    // pickr._root.app.style.display = 'block'
    // }, 50)

    this._pickr = pickr;

    // Append the colour picker in our container
    // this._root.widget.reset.insertAdjacentElement( 'beforebegin', this._pickr._root.app)

    // Reposition the pickr
    // this._pickr._root.app.style.paddingLeft = "20px";
    // this._pickr._root.app.style.position = "static";

    // Build the custom style sheet when a colour is picked
    this._pickr.on("changestop", (instance) => {
    // Get index of active colour
    var index = this._style.activeColourIndex;

    if (typeof index !== "undefined") {
    // Get selected colour hex
    var hex = instance._color.toHEXA().toString(3);

    // Update the colour palette on the page
    this._root.colour[index].style.backgroundColor = hex;

    // Update the colour swatch
    this._style.selectedColours[index].hex = hex;

    // TODO: Push only colours to the publish array that have changed.

    // Build styles
    this._buildStyles();
    } else {
    throw "No Active Colour Selected!";
    }
    });
    }

    // Returns
    // - 0 if has not slides
    // - the active _slide object if _slides has length
    _activeSlide(){
    return this._slides && this._slides.length && this._slides[this._slideInitIndex]
    }

    async _cancel(){
    log.debug('START:CANCEL', this._slides)

    const { editorSpinner } = this._root.editorActions


    // Hide done, cancel actions
    this._hideCropActions()

    // Wait between 0.6s-1.0s
    await new Promise(r => setTimeout(r, Math.floor(Math.random()*300+200)))

    // Reset points
    if (this._slide.backup) {
    const { url } = this._slide.data
    const { points, zoom, orientation } = this._slide.backup
    await this._slide.bind({ url, points, orientation, zoom })
    }

    // Show toolbar, publish settings tools
    this._showEditorActions()

    log.debug('END:CANCEL', this._slides)
    }

    _showEditorActions(){
    const { preview, moveContainer, publish, toolbarContainer, selectMoveContainer, selectContainer, settingsContainer, replace } = this._root.editorActions

    // Show toolbar elements on page
    toolbarContainer.style.opacity="1"
    toolbarContainer.style.zIndex="100"
    selectContainer.style.opacity="1"
    settingsContainer.style.opacity="1"
    settingsContainer.style.zIndex="100"
    publish.style.opacity="1"
    replace.style.display="block"
    selectMoveContainer.style.visibility="visible"
    selectMoveContainer.style.opacity="1"
    selectMoveContainer.style.zIndex="100"
    moveContainer.style.opacity="1"
    preview.style.opacity="1"
    }

    _hideEditorActions(){
    const { preview, edit, selectMoveContainer, captionInput, editorSpinner, urlInput, publish, settingsPanel, cropActions, toolbarContainer, moveContainer, selectContainer, settingsContainer, replace } = this._root.editorActions

    // Hide elements on page
    toolbarContainer.style.opacity="0"
    toolbarContainer.style.zIndex="0"
    selectContainer.style.opacity="0"
    selectContainer.style.zIndex="0"
    settingsContainer.style.opacity="0"
    settingsContainer.style.zIndex="0"
    replace.style.display="none"
    settingsPanel.style.display="none"
    publish.style.opacity="0"
    selectMoveContainer.style.opacity="0"
    selectMoveContainer.style.zIndex="0"
    moveContainer.style.opacity="0"
    preview.style.opacity="0"
    }

    _hideCropActions(){
    const { zoomerWrap, overlay } = this._slide.elements;
    const { cropActions } = this._root.editorActions

    // Hide DONE and CANCEL
    cropActions.style.opacity="0"
    cropActions.style.zIndex="0"

    // Disable interaction
    zoomerWrap.style.opacity = "0";
    overlay.style.pointerEvents = "none";
    }


    async _buildCroppieInstance({
    width,
    height,
    target,
    url,
    mouseWheelZoom = true,
    disabled = false
    }) {
    const { boundaryHeight, boundaryWidth } = this.options

    var c = new Croppie(target, {
    viewport: { width: boundaryWidth, height: boundaryHeight },
    boundary: { width: boundaryWidth, height: boundaryHeight },
    showZoomer: true,
    mouseWheelZoom: mouseWheelZoom,
    enableResize: true,
    disabled: disabled
    });

    var options = {
    url: url,
    zoom: 0,
    };

    await c.bind(options);

    return c;
    }

    _buildCroppieGalleryToolbar() {
    var namespace = this.namespace;
    var images = this._slides;

    var ctnr = this._root.resize.gallery;

    if (ctnr) {
    ctnr.container.parentNode.removeChild(ctnr.container);
    }

    // Append the toolbox gallery html
    var html = createFromTemplate(`
    <div :obj=gallery>
    <div :ref=container>
    <div class="${namespace}-gallery-toolbar">
    ${images.map((img, i) => `<div :arr=item data-seq=${i}>${i}</div>`).join("")}
    </div>
    </div>
    </div>
    `);

    this._root.resize.gallery = html.gallery;

    document
    .querySelector(".cr-boundary")
    .append(this._root.resize.gallery.container);

    // Rebind selected gallery image to main slide
    this._on(this._root.resize.gallery.item, "click", (e) => {
    var i = e.target.dataset.seq;

    var image = this._slides[i];

    this._slide.bind(image);

    e.target.classList.add("active");
    });
    }

    _emit(event, ...args) {
    this._eventListener[event].forEach((cb) => cb(...args, this));
    }

    on(event, cb) {
    // Validate
    if (
    typeof cb === "function" &&
    typeof event === "string" &&
    event in this._eventListener
    ) {
    this._eventListener[event].push(cb);
    }

    return this;
    }
    }

    export default Editor;

    // async function getThemeColours(storeId) {
    // return fetch("Editor/svc/Services.svc/GetThemeColours?storeId=" + storeId)
    // .then((res) => res.json())
    // .then((json) => JSON.parse(json).d);
    // }

    async function getSlideshowImages(storeId) {
    if (isDev){
    return Promise.resolve()
    } else {
    return fetch("Editor/svc/Services.svc/GetSlideShowImages?storeId=" + storeId)
    .then((res) => res.json())
    .then((json) => JSON.parse(json))
    .catch(err => log.error("Something went wrong in getSlideshowImages. Reason:", err))
    }
    // return fetch("Editor/svc/Services.svc/GetSlideShowImages?storeId=" + storeId)
    // .then((res) => res.json())
    // .then((json) => JSON.parse(json));
    }

    var s;
    const storeId = g_fws_sk;


    var str = JSON.stringify;
    var prs = JSON.parse;

    function handleError(err) {
    log.error("Failed to handleResponse. ", err);
    }

    function handleResponse({ request, response }) {
    return new Promise((resolve, reject) => {
    const responseHeaders = {};
    for (var pair of response.headers.entries()) {
    responseHeaders[pair[0]] = pair[1];
    }

    if (response.status !== 200) {
    return Promise.all([
    // Try get text
    // response.text().then((r) => {
    // return Promise.resolve(r);
    // })
    // .catch(e => {
    // return Promise.resolve('Failed to parse TEXT response.' + e)
    // }),

    // Try get JSON
    response.json()
    .then(r => {
    return Promise.resolve('JSON response: ' + r)
    })
    .catch(e => {
    return Promise.resolve('Failed to parse JSON response.' + e)
    })
    ]).then((res) => {
    const msg =
    "Failed to make request. Request headers: " +
    str(request) +
    ". Response headers: " +
    str(responseHeaders) +
    ". Response message: " +
    res;

    reject(msg);
    });
    } else {
    log.debug(
    "Success! Request headers: " +
    str(request) +
    ". Response headers: " +
    str(responseHeaders)
    );

    resolve(responseHeaders)
    }
    });
    }

    function init(event) {
    let mammothBoundaryWidth = 900
    if (document.querySelector(".slider-wrapper.theme-default")) {
    mammothBoundaryWidth = document.querySelector(".slider-wrapper.theme-default").offsetWidth
    }

    const configurations = {
    "storebuilder/89137/electron": {
    storeId: storeId,
    slideshowTarget: document.querySelector("#main-content .row"),
    flex: document.querySelector("#slider"),
    },

    "storebuilder/284203/ritz": {
    storeId: storeId,
    slideshowTarget:document.querySelector(".nivo_container"),
    flex: document.querySelector("#slider"),
    boundaryHeight: 372,
    boundaryWidth: 960
    },

    "storebuilder/309223/mammoth": {
    storeId: storeId,
    slideshowTarget: document.querySelector(".slider-wrapper.theme-default"),
    flex: document.querySelector("#slider"),
    boundaryHeight: 387,

    // TODO: use the slider width for mammoth until the slider is responsiv
    boundaryWidth: mammothBoundaryWidth
    },
    };

    var allowed = [
    "storebuilder/309223/mammoth",
    "storebuilder/284203/ritz",
    "storebuilder/89137/electron",
    ];

    let template;
    if (localStorage && localStorage.getItem("template")) {
    template = localStorage.getItem("template");
    }

    // Only run fow allowed templates
    if (allowed.indexOf(template) === -1) {
    log.debug("Template [%s] is not in allowed list", template);
    return;
    }

    // Index of designs dont need logging, so they are set as isDev
    if (localStorage && localStorage.getItem("isDev")) {
    isDev = localStorage.getItem("isDev") === "true"
    }

    window.log = log = new Logger({
    debugEnabled: true,
    storeId: g_fws_sk,
    isDev: isDev,
    uuid: _.uuid(),
    });


    log.debug(`isDev: [${isDev}], Template: [${template}]`);

    if (navigator && navigator.userAgent) {
    log.debug("User agent:", navigator.userAgent);
    }

    Promise.all([getSlideshowImages(storeId)]).then(
    function ([images]) {
    log.debug("Loaded slides:", images && images.slides && images.slides.length);

    const conf = Object.assign(configurations[template], images)

    log.debug("Initializing Editor with options:", conf)

    window.editor = new Editor(conf);

    editor
    .on("init", (e) => log.debug("INIT", e))
    .on("warn", (e) => log.warn("WARN", e))
    .on("error", function (e) {
    log.error("EDITOR_ERROR", e);

    Swal.fire({
    icon: "error",
    title: "Oops...",
    html: e + "<br><br>" + "[DEBUG_ID] " + log._uuid,
    animation: false,
    confirmButtonColor: "#f054a7",
    });
    })
    }
    ).catch(err => {
    Swal.fire({
    icon: "error",
    title: "Oops...",
    html: err,
    // footer: "<a href>Why do I have this issue?</a>",
    animation: false,
    confirmButtonColor: "#f054a7",
    });

    log.error("Failed to run getSlideshowImages. Reason:", err);
    });
    }

    // Initial Editor, attach advanced logging
    document.addEventListener("DOMContentLoaded", function () {
    init();
    });


    window.addEventListener('unhandledrejection', function(event) {
    const err = "[GLOBAL_ERROR]: " + event.reason
    log.error(err);
    Swal.fire({
    icon: "error",
    title: "DEBUG_ID: ["+log._uuid+"]",
    html: err,
    animation: false,
    confirmButtonColor: "#f054a7",
    });
    event.preventDefault();
    });