Home Manual Reference Source

src/Editor.js

import { editorLogger as logger } from './configuration/LoggerConfig';
import * as FontLoader from './util/FontLoader';
import * as DefaultBehaviors from './configuration/DefaultBehaviors';
import * as DefaultConfiguration from './configuration/DefaultConfiguration';
import * as DefaultStyles from './configuration/DefaultPenStyle';
import * as DefaultTheme from './configuration/DefaultTheme';
import * as InkModel from './model/InkModel';
import * as UndoRedoContext from './model/UndoRedoContext';
import * as UndoRedoManager from './model/UndoRedoManager';
import * as ModelStats from './util/ModelStats';
import * as ImageRenderer from './renderer/canvas/ImageRenderer';
import * as RecognizerContext from './model/RecognizerContext';
import Constants from './configuration/Constants';

/**
 * Trigger callbacks
 * @param {Editor} editor
 * @param {Object} data
 * @param {...String} types
 * @return {Model}
 */
function triggerCallbacks(editor, data, ...types) {
  const editorRef = editor;
  types.forEach((type) => {
    switch (type) {
      case Constants.EventType.RENDERED:
        break; // Internal use only
      case Constants.EventType.UNDO:
      case Constants.EventType.REDO:
      case Constants.EventType.CLEAR:
      case Constants.EventType.CONVERT:
      case Constants.EventType.EXPORT:
        editor.callbacks.forEach(callback => callback.call(editor.domElement, type));
        break;
      case Constants.EventType.LOADED:
      case Constants.EventType.CHANGED:
        editor.callbacks.forEach(callback => callback.call(editor.domElement, type, {
          initialized: editor.initialized,
          canUndo: editor.canUndo,
          canRedo: editor.canRedo,
          canClear: editor.canClear,
          canConvert: editor.canConvert,
          canExport: editor.canExport
        }));
        break;
      case Constants.EventType.EXPORTED:
        window.clearTimeout(editorRef.notifyTimer);
        editorRef.notifyTimer = window.setTimeout(() => {
          editor.callbacks.forEach(callback => callback.call(editor.domElement, type, {
            exports: editor.exports
          }));
        }, editorRef.configuration.processDelay);
        break;
      case Constants.EventType.ERROR:
        editor.callbacks.forEach(callback => callback.call(editor.domElement, type, data));
        break;
      case Constants.EventType.IDLE:
        editor.callbacks.forEach(callback => callback.call(editor.domElement, type, {
          idle: editor.idle
        }));
        break;
      default:
        logger.debug(`No valid trigger configured for ${type}`);
        break;
    }
  });
}

/**
 * Check if a clear is required, and does it if it is
 * @param {function(recognizerContext: RecognizerContext, model: Model, callback: RecognizerCallback)} resetFunc
 * @param {function(recognizerContext: RecognizerContext, model: Model, callback: RecognizerCallback)} func
 * @param {RecognizerContext} recognizerContext Current recognizer context
 * @param {Model} model Current model
 * @param {RecognizerCallback} callback
 */
function manageResetState(resetFunc, func, recognizerContext, model, callback) {
  // If strokes moved in the undo redo stack then a clear is mandatory before sending strokes.
  if (resetFunc && RecognizerContext.isResetRequired(recognizerContext, model)) {
    logger.debug('Reset is needed');
    resetFunc(recognizerContext, model, (err, res, ...types) => {
      if (err) {
        callback(err, res, ...types);
      } else {
        func(recognizerContext, res, callback);
      }
    });
  } else {
    func(recognizerContext, model, callback);
  }
}

/**
 * Check if the trigger in parameter is valid.
 * @param {Editor} editor
 * @param {String} type
 * @param {String} [trigger]
 * @return {Boolean}
 */
function isTriggerValid(editor, type, trigger = editor.configuration.triggers[type]) {
  if (editor.recognizer &&
    editor.recognizer.getInfo().availableTriggers[type].includes(trigger)) {
    return true;
  }
  logger.error(`${trigger} is not a valid trigger for ${type}`);
  return false;
}

/**
 * Manage recognized model
 * @param {Editor} editor
 * @param {Model} model
 * @param {...String} types
 */
function manageRecognizedModel(editor, model, ...types) {
  const editorRef = editor;
  const modelRef = model;
  logger.debug(`model changed callback on ${types} event(s)`, model);
  if (modelRef.creationTime === editor.model.creationTime) {
    // Merge recognized model if relevant and return current editor model
    if ((modelRef.rawStrokes.length === editor.model.rawStrokes.length) &&
      (modelRef.lastPositions.lastSentPosition >= editor.model.lastPositions.lastReceivedPosition)) {
      editorRef.model = InkModel.mergeModels(editorRef.model, modelRef);
      if (InkModel.needRedraw(editorRef.model) || types.includes(Constants.EventType.RENDERED)) {
        editor.renderer.drawModel(editor.rendererContext, editorRef.model, editor.stroker);
      }
    } else {
      editorRef.model = modelRef;
      editor.renderer.drawModel(editor.rendererContext, editorRef.model, editor.stroker);
    }
    triggerCallbacks(editor, undefined, ...types);
  }

  if ((InkModel.extractPendingStrokes(model).length > 0) &&
    (!editor.recognizer.addStrokes) && // FIXME: Ugly hack to avoid double export (addStrokes + export)
    (editor.configuration.triggers.exportContent !== Constants.Trigger.DEMAND)) {
    /* eslint-disable no-use-before-define */
    launchExport(editor, model);
    /* eslint-enable no-use-before-define */
  }
}

/**
 * Recognizer callback
 * @param {Editor} editor
 * @param {Object} error
 * @param {Model} model
 * @param {...String} events
 */
function recognizerCallback(editor, error, model, ...events) {
  const editorRef = editor;

  const handleResult = (err, res, ...types) => {
    if (err) {
      logger.error('Error while firing the recognition', err.stack || err); // Handle any error from all above steps
      if ((err.message === 'Wrong application key') || (err.message === 'Invalid HMAC') ||
      (err.error &&
        err.error.result &&
        err.error.result.error &&
        (err.error.result.error === 'InvalidApplicationKeyException' || err.error.result.error === 'InvalidHMACSignatureException')
      )) {
        editorRef.error.innerText = Constants.Error.WRONG_CREDENTIALS;
      } else {
        editorRef.error.innerText = Constants.Error.NOT_REACHABLE;
      }
      editorRef.error.style.display = 'initial';
      triggerCallbacks(editor, err, Constants.EventType.ERROR, ...types);
    } else {
      manageRecognizedModel(editorRef, res, ...[...events, ...types].filter((el, i, a) => i === a.indexOf(el))); // Remove duplicate events
    }
  };

  logger.debug('recognition callback');
  if (editor.undoRedoManager.updateModel && !error) {
    editor.undoRedoManager.updateModel(editor.undoRedoContext, model, handleResult);
  } else {
    handleResult(error, model, ...events);
  }
}

/**
 * Launch the recognition with all editor relative configuration and state.
 * @param {Editor} editor
 * @param {Model} model
 * @param {String} [trigger]
 */
function addStrokes(editor, model, trigger = editor.configuration.triggers.addStrokes) {
  if (editor.recognizer && editor.recognizer.addStrokes) {
    editor.recognizerContext.initPromise
      .then(() => {
        // Firing addStrokes only if recognizer is configure to do it
        if (isTriggerValid(editor, 'addStrokes', trigger)) {
          manageResetState(editor.recognizer.reset, editor.recognizer.addStrokes, editor.recognizerContext, model, (err, res, ...types) => {
            recognizerCallback(editor, err, res, ...types);
          });
        }
      });
  }
}

/**
 * Launch ink import.
 * @param {Editor} editor
 * @param {Model} model
 * @param {Array<Stroke>} strokes
 */
function launchInkImport(editor, model, strokes) {
  if (editor.recognizer && editor.recognizer.importInk) {
    editor.recognizerContext.initPromise
      .then(() => {
        editor.recognizer.importInk(editor.recognizerContext, model, strokes, (err, res, ...types) => {
          recognizerCallback(editor, err, res, ...types);
        });
      });
  }
}

/**
 * Launch the recognition with all editor relative configuration and state.
 * @param {Editor} editor
 * @param {Model} model
 * @param {String} [trigger]
 */
function launchExport(editor, model, trigger = editor.configuration.triggers.exportContent) {
  if (editor.recognizer && editor.recognizer.exportContent) {
    editor.recognizerContext.initPromise
      .then(() => {
        // Firing export only if recognizer is configure to do it
        if (isTriggerValid(editor, 'exportContent', trigger)) {
          const editorRef = editor;
          window.clearTimeout(editor.exportTimer);
          editorRef.exportTimer = window.setTimeout(() => {
            manageResetState(editor.recognizer.reset, editor.recognizer.exportContent, editor.recognizerContext, model, (err, res, ...types) => {
              recognizerCallback(editor, err, res, ...types);
            });
          }, trigger === Constants.Trigger.QUIET_PERIOD ? editor.configuration.triggerDelay : 0);
        }
      });
  }
}

/**
 * Launch the import.
 * @param {Editor} editor
 * @param {Model} model
 * @param {{x: Number, y: Number}} point Insert point coordinates
 * @param {Blob} data
 */
function launchImport(editor, model, point, data) {
  if (editor.recognizer && editor.recognizer.importContent) {
    editor.recognizerContext.initPromise
      .then(() => {
        editor.recognizer.importContent(editor.recognizerContext, model, point, data, (err, res, ...types) => {
          recognizerCallback(editor, err, res, ...types);
        });
      });
  }
}

/**
 * Launch the convert with all editor relative configuration and state.
 * @param {Editor} editor
 * @param {Model} model
 */
function launchConvert(editor, model) {
  if (editor.recognizer && editor.recognizer.convert) {
    editor.recognizerContext.initPromise
      .then(() => {
        editor.recognizer.convert(editor.recognizerContext, model, (err, res, ...types) => {
          recognizerCallback(editor, err, res, ...types);
        });
      });
  }
}

/**
 * Launch the resize.
 * @param {Editor} editor
 * @param {Model} model
 */
function launchResize(editor, model) {
  if (editor.recognizer && editor.recognizer.resize) {
    editor.recognizerContext.initPromise
      .then(() => {
        const editorRef = editor;
        window.clearTimeout(editor.resizeTimer);
        editorRef.resizeTimer = window.setTimeout(() => {
          editor.recognizer.resize(editor.recognizerContext, model, editor.domElement, (err, res, ...types) => {
            recognizerCallback(editor, err, res, ...types);
          });
        }, editor.configuration.resizeTriggerDelay);
      });
  }
}

/**
 * Launch wait for idle
 * @param {Editor} editor
 * @param {Model} model
 */
function launchWaitForIdle(editor, model) {
  if (editor.recognizer && editor.recognizer.waitForIdle) {
    editor.recognizerContext.initPromise
      .then(() => {
        editor.recognizer.waitForIdle(editor.recognizerContext, model, (err, res, ...types) => {
          recognizerCallback(editor, err, res, ...types);
        });
      });
  }
}

/**
 * Set pen style.
 * @param {Editor} editor
 * @param {Model} model
 */
function setPenStyle(editor, model) {
  if (editor.recognizer && editor.recognizer.setPenStyle) {
    editor.recognizerContext.initPromise
      .then(() => {
        editor.recognizer.setPenStyle(editor.recognizerContext, model, editor.penStyle, (err, res, ...types) => {
          recognizerCallback(editor, err, res, ...types);
        });
      });
  }
}

/**
 * Set theme.
 * @param {Editor} editor
 * @param {Model} model
 */
function setTheme(editor, model) {
  if (editor.recognizer && editor.recognizer.setTheme) {
    editor.recognizerContext.initPromise
      .then(() => {
        editor.recognizer.setTheme(editor.recognizerContext, model, editor.theme, (err, res, ...types) => {
          recognizerCallback(editor, err, res, ...types);
        });
      });
  }
}

/**
 * Editor
 */
export class Editor {

  /**
   * @param {Element} element DOM element to attach this editor
   * @param {Configuration} [configuration] Configuration to apply
   * @param {PenStyle} [penStyle] Custom style to apply
   * @param {Theme} [theme] Custom theme to apply
   * @param {Behaviors} [behaviors] Custom behaviors to apply
   */
  constructor(element, configuration, penStyle, theme, behaviors) {
    /**
     * Inner reference to the DOM Element
     * @type {Element}
     */
    this.domElement = element;
    this.domElement.classList.add('ms-editor');

    // eslint-disable-next-line no-undef
    this.loader = document.createElement('div');
    this.loader.classList.add('loader');
    this.loader = this.domElement.appendChild(this.loader);

    // eslint-disable-next-line no-undef
    this.error = document.createElement('div');
    this.error.classList.add('error-msg');
    this.error = this.domElement.appendChild(this.error);

    /**
     * Launch export timer
     * @type {Number}
     */
    this.exportTimer = undefined;

    /**
     * Launch resize timer
     * @type {Number}
     */
    this.resizeTimer = undefined;

    /**
     * Notify delay timer
     * @type {Number}
     */
    this.notifyTimer = undefined;

    this.theme = theme;
    this.penStyle = penStyle;

    /**
     * @private
     * @type {Behaviors}
     */
    this.innerBehaviors = DefaultBehaviors.overrideDefaultBehaviors(behaviors);
    this.configuration = configuration;

    this.domElement.editor = this;
  }

  /**
   * Set the recognition parameters
   * WARNING : Need to fire a clear if user have already input some strokes.
   * @param {Configuration} configuration
   */
  set configuration(configuration) {
    this.loader.style.display = 'initial';
    this.error.style.display = 'none';
    /**
     * @private
     * @type {Configuration}
     */
    this.innerConfiguration = DefaultConfiguration.overrideDefaultConfiguration(configuration);
    FontLoader.loadFromConfiguration(this.innerConfiguration);
    this.behavior = this.behaviors.getBehaviorFromConfiguration(this.behaviors, this.innerConfiguration);
  }

  /**
   * Get the current recognition parameters
   * @return {Configuration}
   */
  get configuration() {
    return this.innerConfiguration;
  }

  /**
   * Set the pen style
   * @param {PenStyle} penStyle
   */
  set penStyle(penStyle) {
    /**
     * @private
     * @type {PenStyle}
     */
    this.innerPenStyle = DefaultStyles.overrideDefaultPenStyle(penStyle);
    setPenStyle(this, this.model);
  }

  /**
   * Get the pen style
   * @return {PenStyle}
   */
  get penStyle() {
    return this.innerPenStyle;
  }

  /**
   * Set the theme
   * @param {Theme} theme
   */
  set theme(theme) {
    /**
     * @private
     * @type {Theme}
     */
    this.innerTheme = DefaultTheme.overrideDefaultTheme(theme);
    setTheme(this, this.model);
  }

  /**
   * Get the theme
   * @return {Theme}
   */
  get theme() {
    return this.innerTheme;
  }

  /**
   * Get behaviors
   * @return {Behaviors}
   */
  get behaviors() {
    return this.innerBehaviors;
  }

  /**
   * @private
   * @param {Behavior} behavior
   */
  set behavior(behavior) {
    if (behavior) {
      if (this.grabber) { // Remove event handlers to avoid multiplication (detach grabber)
        this.grabber.detach(this.domElement, this.grabberContext);
      }
      /**
       * @private
       * @type {Behavior}
       */
      this.innerBehavior = behavior;
      this.renderer = this.innerBehavior.renderer;
      this.recognizer = this.innerBehavior.recognizer;
      /**
       * Current grabber context
       * @type {GrabberContext}
       */
      this.grabberContext = this.grabber.attach(this.domElement, this);
    }
  }

  /**
   * Get current behavior
   * @return {Behavior}
   */
  get behavior() {
    return this.innerBehavior;
  }

  /**
   * Set the current recognizer
   * @private
   * @param {Recognizer} recognizer
   */
  set recognizer(recognizer) {
    this.undoRedoContext = UndoRedoContext.createUndoRedoContext(this.configuration);
    this.undoRedoManager = UndoRedoManager;

    const initialize = (model) => {
      /**
       * @private
       * @type {Recognizer}
       */
      this.innerRecognizer = recognizer;
      if (this.innerRecognizer) {
        /**
         * Current recognition context
         * @type {RecognizerContext}
         */
        this.recognizerContext = RecognizerContext.createEmptyRecognizerContext(this);
        // FIXME: merge undo/redo manager with default recognizer
        if (this.innerRecognizer.undo && this.innerRecognizer.redo && this.innerRecognizer.clear) {
          this.undoRedoContext = this.recognizerContext;
          this.undoRedoManager = this.innerRecognizer;
        }

        this.innerRecognizer.init(this.recognizerContext, model, (err, res, ...types) => {
          logger.debug('Recognizer initialized', res);
          this.loader.style.display = 'none';
          recognizerCallback(this, err, res, ...types);
        });
      }
    };

    if (recognizer) {
      if (this.innerRecognizer) {
        this.innerRecognizer.close(this.recognizerContext, this.model, (err, res, ...types) => {
          logger.info('Recognizer closed');
          recognizerCallback(this, err, res, ...types);
          initialize(InkModel.clearModel(res));
        });
      } else {
        /**
         * Current model
         * @type {Model}
         */
        this.model = InkModel.createModel(this.configuration);

        // INFO: Recognizer needs model to be initialized
        initialize(this.model);
      }
    }
  }

  /**
   * Get current recognizer
   * @return {Recognizer}
   */
  get recognizer() {
    return this.innerRecognizer;
  }

  /**
   * Set the current renderer
   * @private
   * @param {Renderer} renderer
   */
  set renderer(renderer) {
    if (renderer) {
      if (this.innerRenderer) {
        this.innerRenderer.detach(this.domElement, this.rendererContext);
      }

      /**
       * @private
       * @type {Renderer}
       */
      this.innerRenderer = renderer;
      if (this.innerRenderer) {
        /**
         * Current rendering context
         * @type {Object}
         */
        this.rendererContext = this.innerRenderer.attach(this.domElement);
      }
    }
  }

  /**
   * Get current renderer
   * @return {Renderer}
   */
  get renderer() {
    return this.innerRenderer;
  }

  /**
   * Get current grabber
   * @return {Grabber}
   */
  get grabber() {
    return this.behavior ? this.behavior.grabber : undefined;
  }

  /**
   * Get current stroker
   * @return {Stroker}
   */
  get stroker() {
    return this.behavior ? this.behavior.stroker : undefined;
  }

  /**
   * Get current callbacks
   * @return {Array}
   */
  get callbacks() {
    return this.behavior ? this.behavior.callbacks : undefined;
  }

  /**
   * Get a PNG image data url from the data model
   * @return {String}
   */
  get png() {
    return ImageRenderer.getImage(this.model, this.stroker);
  }

  /**
   * Get statistics to monitor what ink size is send to the server.
   * @return {Stats}
   */
  getStats() {
    return ModelStats.computeStats(this.model);
  }

  /**
   * True if initialized, false otherwise
   * @return {Boolean}
   */
  get initialized() {
    return this.recognizerContext ? this.recognizerContext.initialized : false;
  }

  /**
   * Handle a pointer down
   * @param {{x: Number, y: Number, t: Number}} point Captured point coordinates
   * @param {String} [pointerType=mouse] Current pointer type
   * @param {String} [pointerId] Current pointer id
   */
  pointerDown(point, pointerType = 'pen', pointerId) {
    logger.trace('Pointer down', point);
    window.clearTimeout(this.notifyTimer);
    window.clearTimeout(this.exportTimer);
    this.model = InkModel.initPendingStroke(this.model, point, Object.assign({ pointerType, pointerId }, this.theme.ink, this.penStyle));
    this.renderer.drawCurrentStroke(this.rendererContext, this.model, this.stroker);
    // Currently no recognition on pointer down
  }

  /**
   * Handle a pointer move
   * @param {{x: Number, y: Number, t: Number}} point Captured point coordinates
   */
  pointerMove(point) {
    logger.trace('Pointer move', point);
    this.model = InkModel.appendToPendingStroke(this.model, point);
    this.renderer.drawCurrentStroke(this.rendererContext, this.model, this.stroker);
    // Currently no recognition on pointer move
  }

  /**
   * Handle a pointer up
   * @param {{x: Number, y: Number, t: Number}} point Captured point coordinates
   */
  pointerUp(point) {
    logger.trace('Pointer up', point);
    this.model = InkModel.endPendingStroke(this.model, point);
    this.renderer.drawModel(this.rendererContext, this.model, this.stroker);

    if (this.recognizer.addStrokes) {
      addStrokes(this, this.model);
    } else {
      // Push model in undo redo manager
      recognizerCallback(this, undefined, this.model);
    }
  }

  /**
   * True if idle state
   * @return {Boolean}
   */
  get idle() {
    return this.recognizerContext.idle;
  }

  /**
   * Wait for idle state.
   */
  waitForIdle() {
    triggerCallbacks(this, undefined, Constants.EventType.IDLE);
    launchWaitForIdle(this, this.model);
  }

  /**
   * True if can undo, false otherwise.
   * @return {Boolean}
   */
  get canUndo() {
    return this.undoRedoContext.canUndo;
  }

  /**
   * Undo the last action.
   */
  undo() {
    logger.debug('Undo current model', this.model);
    triggerCallbacks(this, undefined, Constants.EventType.UNDO);
    this.undoRedoManager.undo(this.undoRedoContext, this.model, (err, res, ...types) => {
      manageRecognizedModel(this, res, ...types);
    });
  }

  /**
   * True if can redo, false otherwise.
   * @return {Boolean}
   */
  get canRedo() {
    return this.undoRedoContext.canRedo;
  }

  /**
   * Redo the last action.
   */
  redo() {
    logger.debug('Redo current model', this.model);
    triggerCallbacks(this, undefined, Constants.EventType.REDO);
    this.undoRedoManager.redo(this.undoRedoContext, this.model, (err, res, ...types) => {
      manageRecognizedModel(this, res, ...types);
    });
  }

  /**
   * True if can clear, false otherwise.
   * @return {Boolean}
   */
  get canClear() {
    return this.canUndo && this.model.rawStrokes.length > 0;
  }

  /**
   * Clear the output and the recognition result.
   */
  clear() {
    logger.debug('Clear current model', this.model);
    triggerCallbacks(this, undefined, Constants.EventType.CLEAR);
    this.recognizer.clear(this.recognizerContext, this.model, (err, res, ...types) => {
      recognizerCallback(this, err, res, ...types);
    });
  }

  /**
   * True if can convert, false otherwise.
   * @return {Boolean}
   */
  get canConvert() {
    return this.canUndo && this.canClear && this.recognizer && this.recognizer.convert;
  }

  /**
   * Convert the current content
   */
  convert() {
    if (this.canConvert) {
      triggerCallbacks(this, undefined, Constants.EventType.CONVERT);
      launchConvert(this, this.model);
    }
  }

  /**
   * True if can export, false otherwise.
   * @return {Boolean}
   */
  get canExport() {
    return this.canUndo && this.canClear && this.recognizer && this.recognizer.getInfo().availableTriggers.exportContent.includes(Constants.Trigger.DEMAND);
  }

  /**
   * Explicitly ask to perform an export.
   */
  exportContent() {
    if (this.canExport) {
      triggerCallbacks(this, undefined, Constants.EventType.EXPORT);
      launchExport(this, this.model, Constants.Trigger.DEMAND);
    }
  }

  /**
   * Import content.
   * @param {{x: Number, y: Number}} point Insert point coordinates
   * @param {Blob|*} data Data to import
   * @param {String} [mimetype] Mimetype of the data, needed if data is not a Blob
   */
  importContent(point, data, mimetype) {
    triggerCallbacks(this, undefined, Constants.EventType.IMPORT);
    launchImport(this, this.model, point, !(data instanceof Blob) ? new Blob([data], { type: mimetype }) : data);
  }

  /**
   * Import ink
   * @param {Array<Stroke>} strokes
   */
  importInk(strokes) {
    launchInkImport(this, this.model, strokes);
  }

  /**
   * Get current state exports
   * @return {Object}
   */
  get exports() {
    return this.model ? this.model.exports : undefined;
  }

  /**
   * Function to call when the dom element link to the current ink paper has been resize.
   */
  resize() {
    logger.debug('Resizing editor');
    this.renderer.resize(this.rendererContext, this.model, this.stroker);
    launchResize(this, this.model);
  }
}