Home Reference Source

src/components/tagpad/Tagpad.js

import Two from 'two.js/src/two';
import NestedComponent from '../../components/_classes/nested/NestedComponent';
import _ from 'lodash';
import Formio from '../../Formio';
import editForm from './Tagpad.form';
import NativePromise from 'native-promise-only';

export default class Tagpad extends NestedComponent {
  static schema(...extend) {
    return NestedComponent.schema({
      type: 'tagpad',
      label: 'Tagpad',
      key: 'tagpad',
      dotSize: 10,
      dotStrokeSize: 2,
      dotStrokeColor: '#333',
      dotFillColor: '#ccc',
      components: []
    }, ...extend);
  }

  static builderInfo = {
    title: 'Tagpad',
    group: 'premium',
    icon: 'circle',
    weight: 115,
    documentation: 'http://help.form.io/userguide/',
    schema: Tagpad.schema()
  };

  static editForm = editForm;

  constructor(...args) {
    super(...args);
    this.type = 'tagpad';
    this.dots = [];
    _.defaults(this.component, {
      dotSize: 10,
      dotStrokeSize: 2,
      dotStrokeColor: '#333',
      dotFillColor: '#ccc'
    });
    //init background ready promise
    const backgroundReadyPromise = new NativePromise((resolve, reject) => {
      this.backgroundReady = { resolve, reject };
    });
    this.backgroundReady.promise = backgroundReadyPromise;
    //init dimensions multiplier
    this.dimensionsMultiplier = 1;
  }

  render() {
    if (this.builderMode) {
      return super.render();
    }
    return super.render('coming soon');
  }

  // build(state) {
  //   if (this.options.builder) {
  //     return super.build(state, true);
  //   }
  //   this.createElement();
  //   this.createLabel(this.element);
  //   this.renderTagpad();
  //   this.createDescription(this.element);
  //   if (this.shouldDisable) {
  //     this.disabled = true;
  //   }
  //   this.element.appendChild(this.errorContainer = this.ce('div', { class: 'has-error' }));
  //   this.attachLogic();
  // }

  set disabled(disabled) {
    super.disabled = disabled;
    //call Base Component setter to run the logic for adding disabled class
    Object.getOwnPropertyDescriptor(NestedComponent.prototype, 'disabled').set.call(this, disabled);
  }

  renderTagpad() {
    this.tagpadContainer = this.ce('div', {
      class: 'formio-tagpad-container clearfix'
    });
    this.canvas = this.ce('div', {
      class: 'formio-tagpad-canvas'
    });
    this.background = this.ce('div', {
      class: 'formio-tagpad-background'
    });
    this.canvasContainer = this.ce('div', {
      class: 'formio-tagpad-image-container'
    }, [this.canvas, this.background]);
    this.formContainer = this.ce('div', {
        class: 'formio-tagpad-form-container'
      },
      this.form = this.ce('div', {
        class: 'formio-tagpad-form'
      }));
    this.tagpadContainer.appendChild(this.canvasContainer);
    this.tagpadContainer.appendChild(this.formContainer);
    this.element.appendChild(this.tagpadContainer);
    if (this.hasBackgroundImage) {
      this.two = new Two({
        type: Two.Types.svg
      }).appendTo(this.canvas);
      this.canvasSvg = this.two.renderer.domElement;
      this.addBackground();

      // Stretch drawing area on initial rendering of component.
      // Need a proper moment for that - when background is already displayed in browser so that it already has offsetWidth and offsetHeight
      // For case when component is built before form is initialized:
      this.on('initialized', () => {
        this.stretchDrawingArea();
      });
      // For case when component is built after form is initialized (for ex. when it's on inactive tab of Tabs component), so this.on('initialized', ...) won't be fired:
      this.backgroundReady.promise.then(() => {
        this.stretchDrawingArea();
      });

      this.attach();
      this.redrawDots();
    }
    else {
      this.background.innerHTML = this.t('Background image is not specified. Tagpad doesn\'t work without background image');
    }
  }

  renderForm() {
    // this.form.appendChild(this.ce('p', {
    //     class: 'formio-tagpad-form-title'
    //   },
    //   [
    //     this.t('Dot: '),
    //     this.selectedDotIndexElement = this.ce('span', {}, 'No dot selected')
    //   ]
    //   )
    // );
    // this.component.components.forEach((component) => {
    //   //have to avoid using createComponent method as Components there will be empty
    //   const componentInstance = Components.create(component, this.options, this.data);
    //   componentInstance.parent = this;
    //   componentInstance.root = this.root || this;
    //   const oldOnChange = componentInstance.onChange;
    //   componentInstance.onChange = (flags, fromRoot) => {
    //     oldOnChange.call(componentInstance, flags, fromRoot);
    //     this.saveSelectedDot();
    //   };
    //   this.form.appendChild(componentInstance.getElement());
    //   //need to push to this.components all components with input: true so that saving would work properly
    //   this.addTagpadComponent(componentInstance);
    // });
    // this.form.appendChild(this.ce(
    //   'button',
    //   {
    //     class: 'btn btn-sm btn-danger formio-tagpad-remove-button',
    //     onClick: this.removeSelectedDot.bind(this),
    //     title: 'Remove Dot'
    //   },
    //   [
    //     this.ce('i', {
    //       class: this.iconClass('trash')
    //     })
    //   ]
    // ));
    this.formRendered = true;
  }

  addTagpadComponent(componentInstance) {
    if (componentInstance.component.input) {
      this.components.push(componentInstance);
    }
    else if (componentInstance.components) {
      componentInstance.components.forEach(this.addTagpadComponent.bind(this));
    }
  }

  attach(element) {
    return super.attach(element);
    // this.attachDrawEvents();
    // window.addEventListener('resize', this.stretchDrawingArea.bind(this));
  }

  attachDrawEvents() {
    if (this.options.readOnly) {
      return;
    }
    // Set up mouse event.
    const mouseEnd = (e) => {
      e.preventDefault();
      const offset = this.canvasSvg.getBoundingClientRect();
      this.addDot(this.getActualCoordinate({
        x: e.clientX - offset.left,
        y: e.clientY - offset.top
      }));
    };
    this.canvasSvg.addEventListener('mouseup', mouseEnd);

    // Set up touch event.
    const touchEnd = (e) => {
      e.preventDefault();
      const offset = this.canvasSvg.getBoundingClientRect();
      const touch = e.changedTouches[0];
      this.addDot(this.getActualCoordinate({
        x: touch.pageX - offset.left,
        y: touch.pageY - offset.top
      }));
    };
    this.canvasSvg.addEventListener('touchend', touchEnd);

    this.two.update();
  }

  getActualCoordinate(coordinate) {
    //recalculate coordinate taking into account changed size of drawing area
    coordinate.x = Math.round(coordinate.x / this.dimensionsMultiplier) + this.dimensions.minX;
    coordinate.y = Math.round(coordinate.y / this.dimensionsMultiplier) + this.dimensions.minY;
    return coordinate;
  }

  stretchDrawingArea() {
    const width = this.background.offsetWidth;
    const height = this.background.offsetHeight;
    //don't stretch if background dimensions are unknown yet
    if (width && height) {
      //will need dimensions multiplier for coordinates calculation
      this.dimensionsMultiplier = width / this.dimensions.width;
      this.setEditorSize(width, height);
    }
  }

  get allowData() {
    return true;
  }

  get dataReady() {
    return this.backgroundReady.promise;
  }

  get hasBackgroundImage() {
    return this.component.image || this.component.imageUrl;
  }

  addBackground() {
    if (this.component.image) {
      this.setBackgroundImage(this.component.image);
      this.backgroundReady.resolve();
    }
    else if (this.component.imageUrl) {
      Formio.makeStaticRequest(this.component.imageUrl, 'GET', null, { noToken: true, headers: {} })
        .then(image => {
          this.setBackgroundImage(image);
          this.backgroundReady.resolve();
        })
        .catch(() => {
          //TODO check that component works in this case anyway
          this.background.innerHTML = this.t('Background image failed to load. Tagpad doesn\'t work without background image');
          this.backgroundReady.resolve();
        });
    }
  }

  setBackgroundImage(svgMarkup) {
    const xmlDoc = new DOMParser().parseFromString(svgMarkup, 'image/svg+xml');
    let backgroundSvg = xmlDoc.getElementsByTagName('svg');
    if (!backgroundSvg || !backgroundSvg[0]) {
      console.warn(`Tagpad '${this.component.key}': Background SVG doesn't contain <svg> tag on it`);
      return;
    }
    backgroundSvg = backgroundSvg[0];
    //read initial dimensions from viewBox
    const initialViewBox = backgroundSvg.getAttribute('viewBox');
    let viewBoxMinX, viewBoxMinY, viewBoxWidth, viewBoxHeight;
    if (initialViewBox) {
      [viewBoxMinX, viewBoxMinY, viewBoxWidth, viewBoxHeight] = initialViewBox.split(' ').map(parseFloat);
    }
    else {
      //if viewBox is not defined, use 'x', 'y', 'width' and 'height' SVG attributes (or 0, 0, 640, 480 relatively if any is not defined)
      [viewBoxMinX, viewBoxMinY, viewBoxWidth, viewBoxHeight] = [
        { attribute: 'x', defaultValue: 0 },
        { attribute: 'y', defaultValue: 0 },
        { attribute: 'width', defaultValue: 640 },
        { attribute: 'height', defaultValue: 480 }
      ].map(dimension => {
        return parseFloat(backgroundSvg.getAttribute(dimension.attribute)) || dimension.defaultValue;
      });
    }
    //set initial dimensions to width and height from viewBox of background svg
    this.dimensions = {
      width: viewBoxWidth,
      height: viewBoxHeight,
      minX: viewBoxMinX,
      minY: viewBoxMinY
    };
    //remove width and height attribute for background image to be stretched to available width and preserve aspect ratio
    backgroundSvg.removeAttribute('width');
    backgroundSvg.removeAttribute('height');
    const viewBox = this.dimensions;
    //set background image viewBox
    backgroundSvg.setAttribute('viewBox', `${viewBox.minX} ${viewBox.minY} ${viewBox.width} ${viewBox.height}`);
    //set canvas image viewBox (necessary for canvas SVG to stretch properly without losing correct aspect ration)
    this.canvasSvg.setAttribute('viewBox', `${viewBox.minX} ${viewBox.minY} ${viewBox.width} ${viewBox.height}`);

    svgMarkup = new XMLSerializer().serializeToString(backgroundSvg);
    //fix weird issue in Chrome when it returned '<svg:svg>...</svg:svg>' string after serialization instead of <svg>...</svg>
    svgMarkup = svgMarkup.replace('<svg:svg', '<svg').replace('</svg:svg>', '</svg>');

    this.background.innerHTML = svgMarkup;

    //set dimensions for Two.js instance
    this.setEditorSize(this.dimensions.width, this.dimensions.height);
  }

  setEditorSize(width, height) {
    this.two.width = width;
    this.two.height = height;
    this.two.update();
  }

  addDot(coordinate) {
    const dot = {
      coordinate,
      data: {}
    };
    this.dataValue = this.dataValue || [];
    const newDotIndex = this.dataValue.length;
    const shape = this.drawDot(dot, newDotIndex);
    this.dots.push({
      index: newDotIndex,
      dot,
      shape
    });
    this.dataValue.push(dot);
    this.selectDot(newDotIndex);
    this.triggerChange();
  }

  dotClicked(e, dot, index) {
    //prevent drawing another dot near clicked dot
    e.stopPropagation();
    this.selectDot(index);
  }

  selectDot(index) {
    if (index === null) {
      this.empty(this.form);
      this.components = [];
      this.formRendered = false;
      return;
    }
    if (!this.formRendered) {
      this.renderForm();
    }
    const dot = this.dots[index];
    if (!dot) {
      return;
    }
    //remove dashes for previous selected dot
    if (this.dots[this.selectedDotIndex]) {
      this.dots[this.selectedDotIndex].shape.circle.dashes = [0];
    }
    //add dashes to new selected dot
    dot.shape.circle.dashes = [1];
    this.two.update();
    this.selectedDotIndex = index;
    this.setFormValue(dot.dot.data);
    this.checkDotValidity(this.data, false, dot);
  }

  setFormValue(value) {
    this.selectedDotIndexElement.innerHTML = this.selectedDotIndex + 1;
    this.components.forEach(component => {
      component.setValue(_.get(value, component.key), { noUpdateEvent: true });
    });
  }

  updateValue(value, flags) {
    // Intentionally skip over nested component updateValue method to keep recursive update from occurring with sub components.
    return NestedComponent.prototype.updateValue.call(this, value, flags);
  }

  getValue() {
    return this.dataValue;
  }

  setValue(dots) {
    const changed = this.hasChanged(dots, this.dataValue);
    this.dataValue = dots;
    if (!dots) {
      return;
    }
    this.dots = [];
    dots.forEach((dot, index) => {
      const shape = this.drawDot(dot, index);
      this.dots.push({
        index,
        dot,
        shape
      });
    });
    return changed;
  }

  drawDot(dot, index) {
    //draw circle
    const circle = this.two.makeCircle(dot.coordinate.x, dot.coordinate.y, this.component.dotSize);
    circle.fill = this.component.dotFillColor;
    circle.stroke = this.component.dotStrokeColor;
    circle.linewidth = this.component.dotStrokeSize;
    circle.className += ' formio-tagpad-dot';
    //draw index
    const text = new Two.Text(index + 1, dot.coordinate.x, dot.coordinate.y);
    text.className += ' formio-tagpad-dot-index';
    text.styles = { color: this.component.dotStrokeColor };
    this.two.add(text);
    this.two.update();
    circle._renderer.elem.addEventListener('mouseup', (e) => this.dotClicked(e, dot, index));
    text._renderer.elem.addEventListener('mouseup', (e) => this.dotClicked(e, dot, index));
    return { circle, text };
  }

  saveSelectedDot() {
    const selectedDot = this.dots[this.selectedDotIndex];
    this.components.forEach(component => {
      selectedDot.dot.data[component.key] = component.getValue();
    });
    this.dataValue[this.selectedDotIndex] = selectedDot.dot;
  }

  removeSelectedDot() {
    this.dataValue.splice(this.selectedDotIndex, 1);
    this.redrawDots();
    this.selectDot(0);
  }

  redrawDots() {
    this.dots = [];
    //clear canvas
    this.two.clear();
    this.two.render();
    //draw dots
    this.setValue(this.dataValue);
  }

  checkComponentValidity(data, dirty) {
    if (!this.checkCondition(null, data)) {
      this.setCustomValidity('');
      return true;
    }
    let isTagpadValid = true;
    //check validity of each dot
    this.dots.forEach((dot) => {
      const isDotValid = this.checkDotValidity(data, dirty, dot);
      isTagpadValid = isTagpadValid && isDotValid;
    });
    //in the end check validity of selected dot to show its validation results on the form instead of showing last dot validation
    if (this.selectedDotIndex) {
      this.checkDotValidity(data, dirty, this.dots[this.selectedDotIndex]);
    }
    if (isTagpadValid) {
      this.setCustomValidity('');
    }
    else {
      this.setCustomValidity(this.t('There are some invalid dots'), dirty);
    }
    return isTagpadValid;
  }

  checkDotValidity(data, dirty, dot) {
    const isDotValid = this.components.reduce((valid, component) => {
      component.dataValue = dot.dot.data[component.key];
      return valid && component.checkComponentValidity(data, dirty);
    }, true);
    this.setDotValidity(dot, isDotValid);
    return isDotValid;
  }

  setDotValidity(dot, isValid) {
    let color;
    if (isValid) {
      color = this.component.dotStrokeColor;
    }
    else {
      color = '#ff0000';
    }
    //change style of dot based on its validity
    dot.shape.circle.stroke = color;
    dot.shape.text.styles.color = color;
    this.two.update();
  }

  addInputError(message, dirty) {
    //need to override this to not add has-error class (because has-error highlights all inner form-controls with red)
    if (!message) {
      return;
    }

    if (this.errorElement) {
      const errorMessage = this.ce('p', {
        class: 'help-block'
      });
      errorMessage.appendChild(this.text(message));
      this.errorElement.appendChild(errorMessage);
    }

    this.inputs.forEach((input) => this.addClass(this.performInputMapping(input), 'is-invalid'));
    if (dirty && this.options.highlightErrors) {
      this.addClass(this.element, 'formio-error-wrapper');
    }
  }
}