Home Reference Source Repository

src/es6/sorter.es6

import {d3, uuid} from "nbpresent-deps";

import Jupyter from "base/js/namespace";

import {Editor} from "./editor";
import {Toolbar} from "./toolbar";
import {PART} from "./parts";
import {MiniSlide} from "./mini";
import {TemplateLibrary} from "./templates";

let REMOVED = "<removed>";

class Sorter {
  constructor(tree, tour) {
    this.tree = tree;
    this.tour = tour;

    this.templatePicked = this.templatePicked.bind(this);

    this.visible = this.tree.select(["sorting"]);
    this.visible.set(false);

    this.slides = this.tree.select(["slides"]);
    this.selectedSlide = this.tree.select(["selectedSlide"]);
    this.selectedRegion = this.tree.select(["sorter", "selectedRegion"]);

    this.selectedSlide.on("update", () => this.updateSelectedSlide());
    this.selectedRegion.on("update", () => this.updateSelectedRegion());
    this.slides.on("update", () => this.draw());
    this.visible.on("update", () => this.visibleUpdated());

    this.scale = {
      x: d3.scale.linear()
    };

    this.mini = new MiniSlide(this.selectedRegion);

    this.drawn = false;
  }

  visibleUpdated(){
    let visible = this.visible.get();

    if(!this.drawn){
      this.initUI();
      this.drawn = true;
    }

    if(visible) {
      this.draw();
    }else {
      if(this.editor){
        this.editor.destroy();
        this.editor = null;
      }
      if(this.templates){
        this.templates.destroy();
        this.templates = null;
      }
    }

    this.update();
  }

  show(){
    this.visible.set(!this.visible.get());
  }

  update(){
    let visible = this.visible.get();
    this.$view
      .classed({offscreen: !visible})
      .style({
        // necessary for FOUC
        "display": visible ? "block" : "none"
      });

    d3.select("#notebook")
      .style({
        "margin-bottom": visible ? "100px" : null
      });
  }

  initUI(){
    this.$view = d3.select("body")
      .append("div")
      .classed({
        nbpresent_sorter: 1,
        offscreen: 1
      })
      .style({
        display: "none"
      });

    this.$slides = this.$view.append("div")
      .classed({slides_wrap: 1});

    let $brand = this.$view.append("h4")
      .classed({nbpresent_brand: 1})
      .append("a")
      .attr({
        href: "https://continuumio.github.io/nbpresent/",
        target: "_blank"
      })

    $brand.append("i")
      .attr("class", "fa fa-fw fa-gift");

    $brand.append("span")
      .text("nbpresent");

    this.$empty = this.$slides.append("h3")
      .classed({empty: 1})
      .text("No slides yet.");

    let $help = this.$view.append("div")
      .classed({nbpresent_help: 1})
      .append("a")
      .attr({href: "#"})
    .on("click", () => this.tour.restart())
      .append("i")
      .attr("class", "fa fa-question-circle");

    this.initToolbar();
    this.initDrag();
  }

  initDrag(){
    let that = this,
      dragOrigin;

    this.drag = d3.behavior.drag()
      .on("dragstart", function(d){
        let slide = d3.select(this)
          .classed({dragging: 1});

        dragOrigin = parseFloat(slide.style("left"));
      })
      .on("drag", function(d){
        d3.select(this)
          .style({
            left: `${dragOrigin += d3.event.dx}px`,
          });
      })
      .on("dragend", function(d, i){
        let $slide = d3.select(this)
          .classed({dragging: 0});

        let left = parseFloat($slide.style("left")),
          slides = that.tree.get("sortedSlides"),
          slideN = Math.floor(left / that.slideWidth()),
          after;

        if(left < that.slideWidth() || slideN < 0){
          after = null;
        }else if(slideN > slides.length || !slides[slideN]){
          after = slides.slice(-1)[0].key;
        }else{
          after = slides[slideN].key;
        }

        if(d.key !== after){
          that.unlinkSlide(d.key);
          that.selectedSlide.set(that.appendSlide(after, d.key));
        }else{
          that.draw();
        }
      });
  }

  // TODO: make these d3 scales!
  slideHeight() {
    return 100;
  }

  slideWidth(){
    return 200;
  }

  copySlide(slide){
    let newSlide = JSON.parse(JSON.stringify(slide));
    newSlide.id = this.nextId();
    newSlide.regions = d3.entries(newSlide.regions).reduce((memo, d)=>{
      let id = this.nextId();
      // TODO: keep part?
      memo[id] = $.extend(true, {}, d.value, {id, content: null});
      return memo;
    }, {});

    return newSlide;
  }

  slideClicked(d){
    if(this.templates){
      let newSlide = this.copySlide(d.value);
      this.templatePicked({key: newSlide.id, value: newSlide});
      this.templates && this.templates.destroy();
      this.templates = null;
      return;
    }
    this.selectedSlide.set(d.key);
  }

  draw(){
    if(!this.$slides){
      return;
    }
    let that = this;

    let slides = this.tree.get("sortedSlides");

    this.scale.x.range([20, this.slideWidth() + 20]);

    let $slide = this.$slides.selectAll(".slide")
      .data(slides, (d) => d.key);

    $slide.enter().append("div")
      .classed({slide: 1})
      .call(this.drag)
      .on("click", (d) =>{
        this.slideClicked(d)
      })
      .on("dblclick", (d) => this.editSlide(d.key));

    $slide.exit()
      .transition()
      .style({
        top: "200px"
      })
      .remove();

    let selectedSlide = this.selectedSlide.get(),
      selectedRegion = this.selectedRegion.get(),
      selectedSlideLeft;

    $slide
      .style({
        "z-index": (d, i) => i
      })
      .classed({
        active: (d) => d.key === selectedSlide
      })
      .transition()
      .delay((d, i) => i * 10)
      .style({
        left: (d, i) => {
          let left = this.scale.x(i);
          if(d.key === selectedSlide){
            selectedSlideLeft = left;
          }
          return `${left}px`;
        }
      });

    $slide.call(this.mini.update);

    this.$regionToolbar
      .transition()
      .style({
        opacity: selectedRegion ? 1 : 0,
        display: selectedRegion ? "block" : "none",
        left: `${selectedSlideLeft - 30}px`
      });

    this.$empty
      .transition()
      .style({opacity: 1 * !$slide[0].length })
      .transition()
      .style({display: $slide[0].length ? "none": "block"});

    this.$deckToolbar.call(this.deckToolbar.update);
  }

  updateSelectedRegion(){
    let {slide, region} = this.selectedRegion.get() || {};
    if(region){
      this.selectedSlide.set(slide);
      let content = this.slides.get(
        [slide, "regions", region, "content"]);
      if(content){
        this.selectCell(content.cell);
      }
    }else if(this.$regionToolbar){
      this.$regionToolbar.transition()
        .style({opacity: 0})
        .transition()
        .style({display: "none"});
    }
    this.draw();
  }

  // TODO: move this to the cell manager?
  selectCell(id){
    let cell = Jupyter.notebook.get_cells().filter(function(cell, idx){
      if(cell.metadata.nbpresent && cell.metadata.nbpresent.id == id){
        Jupyter.notebook.select(idx);
      };
    });
  }

  updateSelectedSlide(){
    let slide = this.selectedSlide.get(),
      selected = this.selectedRegion.get() || {};

    if(selected.slide != slide){
      this.selectedRegion.set(null);
    }

    this.draw();
    if(this.editor){
      this.editSlide(slide);
    }
  }


  initToolbar(){
    this.deckToolbar = new Toolbar()
      .btnClass("btn-default btn-lg");
    this.$deckToolbar = this.$view.append("div")
      .classed({deck_toolbar: 1})
      .datum([
        [{
          icon: "plus-square-o fa-2x",
          click: () => this.addSlide(),
          tip: "Add Slide"
        }], [{
          icon: "edit fa-2x",
          click: () => this.editSlide(this.selectedSlide.get()),
          tip: "Edit Slide",
          visible: () => this.selectedSlide.get() && !this.editor
        }, {
          icon: "chevron-circle-down fa-2x",
          click: () => this.editSlide(this.selectedSlide.get()),
          tip: "Back to Sorter",
          visible: () => this.editor
        }],
        [{
          icon: "trash fa-2x",
          click: () => {
            this.removeSlide(this.selectedSlide.get());
            this.selectedSlide.set(null);
          },
          tip: "Delete Slide",
          visible: () => this.selectedSlide.get()
        }]
      ])
      .call(this.deckToolbar.update);

    this.regionToolbar = new Toolbar();
    this.$regionToolbar = this.$slides.append("div")
      .classed({region_toolbar: 1})
      .datum([
        [{
          icon: "terminal",
          click: () => this.linkContent(PART.source),
          tip: "Link Region to Cell Input"
        },
        {
          icon: "image",
          click: () => this.linkContent(PART.outputs),
          tip: "Link Region to Cell Output"
        },
        {
          icon: "sliders",
          click: () => this.linkContent(PART.widgets),
          tip: "Link Region to Cell Widgets"
        },
        {
          icon: "unlink",
          click: () => this.linkContent(null),
          tip: "Unlink Region"
        }]
      ])
      .call(this.regionToolbar.update);
  }

  linkContent(part){
    let {slide, region} = this.selectedRegion.get() || {},
      cell = Jupyter.notebook.get_selected_cell(),
      cellId;

    if(!(region && cell)){
      return;
    }

    if(part){
      if(!cell.metadata.nbpresent){
        cell.metadata.nbpresent = {id: this.nextId()};
      }

      cellId = cell.metadata.nbpresent.id;

      this.slides.set([slide, "regions", region, "content"], {
        cell: cellId,
        part
      });
    }else{
      this.slides.unset([slide, "regions", region, "content"]);
    }
  }

  addSlide(){
    if(!this.templates || this.templates.killed){
      this.templates = new TemplateLibrary(this.templatePicked);
    }else{
      this.templates.destroy();
      this.templates = null;
    }
  }

  templatePicked(slide){
    if(slide && this.templates && !this.templates.killed){
      let last = this.tree.get("sortedSlides").slice(-1),
        selected = this.selectedSlide.get();

      this.slides.set([slide.key], slide.value);

      let appended = this.appendSlide(
          selected ? selected : last.length ? last[0].key : null,
          slide.key
        );

      this.selectedSlide.set(appended);
    }
    if(this.templates){
      this.templates.destroy();
      this.templates = null;
    }
  }

  editSlide(id){
    if(this.editor){
      if(this.editor.slide.get("id") === id){
        id = null;
      }
      this.editor.destroy();
      this.editor = null;
    }

    if(id){
      // TODO: do this with an id and big tree ref?
      this.editor = new Editor(this.slides.select(id), this.selectedRegion);
    }
    this.draw();
  }

  nextId(){
    return uuid.v4();
  }

  unlinkSlide(id){
    let {prev} = this.slides.get(id),
      next = this.nextSlide(id);

    next && this.slides.set([next, "prev"], prev);
    this.slides.set([id, "prev"], REMOVED);
  }

  removeSlide(id){
    if(!id){
      return;
    }
    this.unlinkSlide(id);
    this.slides.unset(id);
  }

  nextSlide(id){
    let slides = this.tree.get("sortedSlides"),
      next = slides.filter((d) => d.value.prev === id);

    return next.length ? next[0].key : null;
  }

  newSlide(id, prev){
    return {
      id,
      prev,
      regions: {}
    }
  }

  appendSlide(prev, id=null){
    let next = this.nextSlide(prev);

    if(!id){
      id = this.nextId();
      this.slides.set(id, this.newSlide(id, prev));
    }else{
      this.slides.set([id, "prev"], prev);
    }

    next && this.slides.set([next, "prev"], id);

    return id;
  }
}

export {Sorter};