Source: canvas.js

import * as Drawable from './drawable.js';
import * as Topbar from './topbar.js';
import * as Editor from './editor-page.js';

/**
 * Represents a <canvas> HTML object with methods to
 * manipulate what's drawn on screen
 *
 * Instance variables:
 *  - canvas: The \<canvas\> object to draw to
 *  - draw_stack: The order in which to draw objects
 *  - focus: The currently selected object
 *  - active: Whether the Canvas is currently active
 *  - toolbar: The toolbar to pull from
 *  - attr: The attribute menu to pull from and write to
 */
export class Canvas {
  /**
   * Initializes the Canvas object.
   *
   * @constructor
   * @param {string} id The HTML identifier for what \<canvas\> element this
   *    Canvas object is attached to
   * @param {boolean} active Whether this \<canvas\> is active at startup
   */
  constructor(id, active) {
    this.canvas = document.querySelector(id);
    this.canvas.width = 1080;
    this.canvas.height = 600;

    this.backgroundColor = '#f7fdfc';

    this.draw_stack = [];
    this.focus = null;

    this.onMouseDown = this.onMouseDown.bind(this);
    this.onDrag = this.onDrag.bind(this);
    this.onMouseUp = this.onMouseUp.bind(this);

    this.shiftHeld = false;
    this.onKeyDown = this.onKeyDown.bind(this);
    this.onKeyUp = this.onKeyUp.bind(this);

    this.onClickElsewhere = this.onClickElsewhere.bind(this);

    this.setActive(active);
  }

  /**
   * Makes this Canvas object either active or inactive, resetting
   * all relevant listeners
   *
   * @param {boolean} active Whether to be active or not
   */
  setActive(active) {
    this.active = active;

    if (active) {
      this.canvas.addEventListener('mousedown', this.onMouseDown);
      document.addEventListener('keydown', this.onKeyDown);
      document.addEventListener('keyup', this.onKeyUp);

      document.addEventListener('mousedown', this.onClickElsewhere);
    } else {
      this.shiftHeld = false;
      this.focus = null;

      this.canvas.removeEventListener('mousedown', this.onMouseDown);
      document.removeEventListener('keydown', this.onKeyDown);
      document.removeEventListener('keyup', this.onKeyUp);

      document.removeEventListener('mousedown', this.onClickElsewhere);
    }

    if (this.attr) { this.attr.setObject(this.focus); }
    this.renderCanvas();
  }

  /**
   * Attaches the selected toolbar to this Canvas object
   *
   * @param {Toolbar} toolbar Toolbar to attach
   */
  attachToolbar(toolbar) {
    this.toolbar = toolbar;
  }

  /**
   * Attaches the selected attribute menu to this Canvas object
   *
   * @param {AttributeMenu} attr Attribute menu to attach
   */
  attachAttributeMenu(attr) {
    this.attr = attr;
  }

  /**
   * Exports this Canvas object into JSON format, giving a
   * description thorough enough to reconstruct it at a later time
   *
   * @returns {Map<string,Object>} JSON formatted dictionary
   */
  exportJSON() {
    const exp = {
      width: this.canvas.width,
      height: this.canvas.height
    };

    const stack = [];
    for (let i = 0; i < this.draw_stack.length; i++) {
      const obj = this.draw_stack[i];
      let type;
      if (obj instanceof Drawable.Textbox) type = 'textbox';
      else if (obj instanceof Drawable.Image) type = 'image';
      else if (obj instanceof Drawable.Ellipse) type = 'ellipse';
      else if (obj instanceof Drawable.Box) type = 'box';
      else continue;

      stack.push({
        type,
        data: obj.export()
      });
    }

    exp.objects = stack;
    return exp;
  }

  /**
   * Reconstructs this Canvas object from a previous JSON export
   *
   * @param {Map<string,Object>} description JSON formatted dictionary
   */
  importJSON(description) {
    if (!description) return;
    if ('width' in description && typeof description.width === 'number') { this.canvas.width = description.width; }
    if ('height' in description && typeof description.height === 'number') { this.canvas.height = description.height; }

    this.draw_stack = [];
    if (!('objects' in description)) { return; }

    const stack = description.objects;
    for (let i = 0; i < stack.length; i++) {
      const data = stack[i];
      const type = data.type;
      let obj;

      switch (type) {
        case 'textbox': obj = new Drawable.Textbox(this); break;
        case 'image': obj = new Drawable.Image(this); break;
        case 'ellipse': obj = new Drawable.Ellipse(this); break;
        case 'box': obj = new Drawable.Box(this); break;
        default: continue;
      }

      obj.import(data.data);
      this.draw_stack.push(obj);
    }

    this.renderCanvas();
  }

  /**
   * Handles a mousedown event when the currently selected tool is 'select'
   *
   * @param {MouseEvent} e The mousedown event
   */
  selector(e) {
    const scaleX = this.canvas.width / this.canvas.getBoundingClientRect().width;
    const scaleY = this.canvas.height / this.canvas.getBoundingClientRect().height;

    const x = (e.clientX - this.canvas.getBoundingClientRect().x) * scaleX;
    const y = (e.clientY - this.canvas.getBoundingClientRect().y) * scaleY;

    if (this.focus !== null && this.focus.overSelection(x, y)) {
      if (this.focus.onMouseDown(e)) {
        document.addEventListener('mousemove', this.onDrag);
        document.addEventListener('mouseup', this.onMouseUp);
      };

      return;
    }

    for (let i = this.draw_stack.length - 1; i >= 0; i--) {
      if (this.draw_stack[i].overSelf(x, y)) {
        if (this.focus !== null && this.focus !== this.draw_stack[i]) {
          this.focus.selected = false;
        }

        this.focus = this.draw_stack[i];
        if (this.draw_stack[i].onMouseDown(e)) {
          document.addEventListener('mousemove', this.onDrag);
          document.addEventListener('mouseup', this.onMouseUp);
        };

        return;
      }
    }

    if (this.focus !== null) {
      this.focus.selected = false;
      this.focus = null;
    }
  }

  /**
   * Handles clicks that aren't on the \<canvas\> element.
   *
   * If this Canvas isn't active or we clicked on the \<canvas\>, return
   * and let onMouseDown handle the evetn.
   *
   * If the click was on over the attribute selector menu, only disable
   * keyboard shortcuts (we don't want to click off the currently selected
   * element but want the user to interact with the menu smoothly)
   *
   * Otherwise, deselect the currently selected element
   *
   * @param {MouseEvent} e The click event
   */
  onClickElsewhere(e) {
    if (!this.active) { return; }

    const popupMenu = document.querySelector('#popup-menu');
    if (popupMenu && popupMenu.style.display !== 'none') {
      const bbox1 = popupMenu.getBoundingClientRect();
      const over = (bbox1.x <= e.clientX && e.clientX <= bbox1.x + bbox1.width &&
        bbox1.y <= e.clientY && e.clientY <= bbox1.y + bbox1.height) ||
        e.clientY <= 55;

      if (!over) { Topbar.removePopupMenu(); }
    }

    if (e.target === this.canvas) {
      this.enableKeyboardShortcuts = true;
      return;
    }

    const bbox = document.querySelector('#attribute-sel').getBoundingClientRect();
    const over = (bbox.x <= e.clientX && e.clientX <= bbox.x + bbox.width &&
                  bbox.y <= e.clientY && e.clientY <= bbox.y + bbox.height);

    if (over) {
      this.enableKeyboardShortcuts = false;
    } else {
      this.enableKeyboardShortcuts = false;
      this.focus = null;

      this.attr.setObject(this.focus);
      this.renderCanvas();
    }
  }

  /**
   * Handles clicks on the \<canvas\> element.
   *
   * If the current tool is 'selector', call selector() and let it
   * handle it.
   *
   * Otherwise, create a new element and be ready to modify it (in the case
   * of squares or circles) or to simply select it and go to the selector tool
   *
   * @param {MouseEvent} e The mousedown event
   */
  onMouseDown(e) {
    const tool = this.toolbar.getCurTool();

    if (tool === 0) {
      this.selector(e);
    } else {
      let element;
      switch (tool) {
        case 1: // line tool, doesn't exist anymore
          element = new Drawable.Line(this);
          break;
        case 2: // box tool
          element = new Drawable.Box(this);
          break;
        case 3: // ellipse tool
          element = new Drawable.Ellipse(this);
          break;
        case 4: // image tool
          element = new Drawable.Image(this);
          break;
        case 5: // textbox tool
          element = new Drawable.Textbox(this);
          break;
      }

      this.draw_stack.push(element);
      this.focus = element;
      if (element.init(e)) {
        document.addEventListener('mousemove', this.onDrag);
        document.addEventListener('mouseup', this.onMouseUp);
      } else {
        this.onAnyChange();
        this.focus.selected = true;
        this.toolbar.setTool(0);
      }
    }

    this.attr.setObject(this.focus);
    this.renderCanvas();
  }

  /**
   * Handle moving the cursor after a mousedown event, either
   * to drag elements around or to resize them
   *
   * @param {MouseEvent} e The drag event
   */
  onDrag(e) {
    if (this.focus !== null) { this.focus.onDrag(e); }

    this.attr.updateObject();
    this.renderCanvas();
  }

  /**
   * Handle letting go of a mouse press
   *
   * @param {MouseEvent} e  The mouseup event
   */
  onMouseUp(e) {
    if (this.focus !== null && !this.focus.onMouseUp(e)) {
      this.focus = null;
    } else {
      this.focus.selected = true;
    }

    document.removeEventListener('mousemove', this.onDrag);
    document.removeEventListener('mouseup', this.onMouseUp);

    this.toolbar.setTool(0);

    this.onAnyChange();
    this.attr.updateObject();
    this.renderCanvas();
  }

  /**
   * Handles a left key trigger by the user.
   *
   * Currently, only moves objects left when keyboard shortcuts
   * are enabled.
   *
   * @param {boolean} meta Whether the meta button is held
   * @param {boolean} shift Whether the shift button is held
   */
  triggerLeftKey(meta, shift) {
    if (this.focus === null) { return; }

    this.focus.moveX(shift ? -1 : -10);
    this.renderCanvas();
  }

  /**
   * Handles a right key trigger by the user.
   *
   * Currently, only moves objects right when keyboard shortcuts
   * are enabled.
   *
   * @param {boolean} meta Whether the meta button is held
   * @param {boolean} shift Whether the shift button is held
   */
  triggerRightKey(meta, shift) {
    if (this.focus === null) { return; }

    this.focus.moveX(shift ? 1 : 10);
    this.renderCanvas();
  }

  /**
   * Handles a down key trigger by the user.
   *
   * Moves objects down when the meta button is not held.
   *
   * Moves objects down in the render stack when the meta button
   * is held, either by one or to the bottom depending on whether
   * the shift button is held.
   *
   * @param {boolean} meta Whether the meta button is held
   * @param {boolean} shift Whether the shift button is held
   */
  triggerDownKey(meta, shift) {
    if (this.focus === null) { return; }

    if (!meta) {
      this.focus.moveY(shift ? 1 : 10);
      this.renderCanvas();
      return;
    }

    let itemId;
    for (let i = 0; i < this.draw_stack.length; i++) {
      if (this.draw_stack[i] === this.focus) {
        itemId = i;
        break;
      }
    }

    if (shift) {
      this.draw_stack.splice(itemId, 1);
      this.draw_stack.unshift(this.focus);
    } else if (itemId > 0) {
      this.draw_stack[itemId] = this.draw_stack[itemId - 1];
      this.draw_stack[itemId - 1] = this.focus;
    }

    this.renderCanvas();
  }

  /**
   * Handles a up key trigger by the user.
   *
   * Moves objects up when the meta button is not held.
   *
   * Moves objects up in the render stack when the meta button
   * is held, either by one or to the top depending on whether
   * the shift button is held.
   *
   * @param {boolean} meta Whether the meta button is held
   * @param {boolean} shift Whether the shift button is held
   */
  triggerUpKey(meta, shift) {
    if (this.focus === null) { return; }

    if (!meta) {
      this.focus.moveY(shift ? -1 : -10);
      this.renderCanvas();
      return;
    }

    let itemId;
    for (let i = 0; i < this.draw_stack.length; i++) {
      if (this.draw_stack[i] === this.focus) {
        itemId = i;
        break;
      }
    }

    if (shift) {
      this.draw_stack.splice(itemId, 1);
      this.draw_stack.push(this.focus);
    } else if (itemId < this.draw_stack.length - 1) {
      this.draw_stack[itemId] = this.draw_stack[itemId + 1];
      this.draw_stack[itemId + 1] = this.focus;
    }

    this.renderCanvas();
  }

  /**
   * Handle a delete key trigger by the user.
   *
   * Deletes the currently selected object.
   */
  triggerDeleteKey() {
    if (this.focus === null) { return; }

    let itemId;
    for (let i = 0; i < this.draw_stack.length; i++) {
      if (this.draw_stack[i] === this.focus) {
        itemId = i;
        break;
      }
    }

    this.draw_stack.splice(itemId, 1);
    this.focus = null;

    this.attr.setObject(this.focus);
    this.renderCanvas();
  }

  /**
   * Handles keyboard presses by the user, delegating the task
   * handling to their respective functions.
   *
   * @param {KeyboardEvent} e The keypress event
   */
  onKeyDown(e) {
    const key = e.key;
    if (key === 'Shift') {
      this.shiftHeld = true;
    }

    if (!this.enableKeyboardShortcuts) { return; } // shortcuts aren't enabled right now

    if (key === 'ArrowDown') {
      this.triggerDownKey(e.metaKey, e.shiftKey);
    } else if (key === 'ArrowUp') {
      this.triggerUpKey(e.metaKey, e.shiftKey);
    } else if (key === 'ArrowLeft') {
      this.triggerLeftKey(e.metaKey, e.shiftKey);
    } else if (key === 'ArrowRight') {
      this.triggerRightKey(e.metaKey, e.shiftKey);
    } else if (key === 'Delete' || key === 'Backspace') {
      this.triggerDeleteKey();
    }

    this.onAnyChange();
  }

  /**
   * Handles a user letting go of a key.
   *
   * Only used for tracking whether shift is held for now.
   *
   * @param {KeyboardEvent} e The keypress event
   */
  onKeyUp(e) {
    const key = e.key;
    if (key === 'Shift') {
      this.shiftHeld = false;
    }
  }

  /**
   * Renders the canvas, going from the bottom to the top
   * of the draw stack. Each object has its own drawing
   * functionality, so hand it off to them. Finally draw the
   * focus box of the currently selected item.
   */
  renderCanvas() {
    const ctx = this.canvas.getContext('2d');
    ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);

    ctx.fillStyle = this.backgroundColor;
    ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);

    for (let i = 0; i < this.draw_stack.length; i++) {
      this.draw_stack[i].drawSelf(ctx);
    }

    if (this.focus !== null) { this.focus.drawFocus(ctx); }
  }

  /**
   * Triggers on any change to the card.
   * Updates the name in the top bar to reflect
   * changes to the card.
   */
  onAnyChange() {
    if (Editor.saved) {
      Editor.setSaved(false);
      Topbar.setName(Editor.cardName);
    }
  }
}