Home Reference Source

scripts/simple/app.js

import nstructjs from '../path-controller/util/struct.js';

export const DataModelClasses = [];
import {ToolStack} from '../path-controller/toolsys/toolsys.js';
import {Context} from '../path-controller/controller/context.js';
import {DataAPI} from '../path-controller/controller/controller.js';
import {Screen} from '../screen/FrameManager.js';
import {areaclasses} from '../screen/area_wrangler.js';
import * as util from '../util/util.js';
import {Editor} from './editor.js';

import {Vector2} from '../path-controller/util/vectormath.js';

import cconst from '../config/const.js';

export class DataModel {
  static defineAPI(api, strct) {
    return strct;
  }

  static register(cls) {
    if (!cls.hasOwnProperty("defineAPI")) {
    //  throw new Error(cls.name + "is missing a defineAPI method");
    }

    DataModelClasses.push(cls);

    if (cls.hasOwnProperty("STRUCT")) {
      nstructjs.register(cls);
    }
  }
}

class EmptyContextClass extends Context {
  static defineAPI(api, strct) {
  }
}

import * as ui_noteframe from '../widgets/ui_noteframe.js';

/**
 * Extend the client-provided context class
 * with a few standard methods and properties
 *
 * */
function GetContextClass(ctxClass) {
  let StateSymbol = Symbol("AppState ref");

  return class ContextDerived extends ctxClass {
    constructor(state) {
      super(...arguments);

      this[StateSymbol] = state;
    }

    get screen() {
      return this[StateSymbol].screen;
    }

    set state(v) {
      this[StateSymbol] = v;
    }

    get state() {
      return this[StateSymbol];
    }

    get api() {
      return this[StateSymbol].api;
    }

    get toolstack() {
      return this[StateSymbol].toolstack;
    }

    toLocked() {
      //for now, don't support context locking
      return this;
    }

    message(msg, timeout=2500) {
      return ui_noteframe.message(this.screen, msg, timeout);
    }

    error(msg, timeout=2500) {
      return ui_noteframe.error(this.screen, msg, timeout);
    }

    warning(msg, timeout=2500) {
      return ui_noteframe.warning(this.screen, msg, timeout);
    }

    progressBar(msg, percent, color, timeout=1000) {
      return ui_noteframe.progbarNote(this.screen, msg, percent, color, timeout);
    }
  }
}

export function makeAPI(ctxClass) {
  let api = new DataAPI();

  for (let cls of DataModelClasses) {
    if (cls.defineAPI) {
      cls.defineAPI(api, api.mapStruct(cls, true));
    }
  }

  for (let k in areaclasses) {
    areaclasses[k].defineAPI(api, api.mapStruct(areaclasses[k], true));
  }

  if (ctxClass.defineAPI) {
    ctxClass.defineAPI(api, api.mapStruct(ctxClass, true));
  } else {
    throw new Error("Context class should have a defineAPI static method");
  }

  api.rootContextStruct = api.mapStruct(ctxClass, api.mapStruct(ctxClass, true));

  return api;
}

import {Icons, loadDefaultIconSheet} from './icons.js';
import {IconManager, setIconManager, setIconMap, setTheme, UIBase} from '../core/ui_base.js';
import {FileArgs, loadFile, saveFile} from './file.js';
import {HotKey, KeyMap} from '../path-controller/util/simple_events.js';
import {initSplineTemplates} from '../path-controller/curve/curve1d_bspline.js';
import {MenuBarEditor, registerMenuBarEditor} from './menubar.js';
import {register} from './app_ops.js';

export class StartArgs {
  constructor() {
    this.singlePage = true;

    this.icons = Icons;
    this.iconsheet = undefined; //will default to loadDefaultIconSheet();
    this.iconSizes = [16, 24, 32, 48];
    this.iconTileSize = 32;
    this.iconsPerRow = 16;
    this.theme = undefined; //see scripts/core/theme.js

    this.registerSaveOpenOps = true;

    this.autoLoadSplineTemplates = true;
    this.showPathsInToolTips = true;
    this.enableThemeAutoUpdate = false;
    this.addHelpPickers = false;
    this.useNumSliderTextboxes = true;
    this.numSliderArrowLimit = cconst.numSliderArrowLimit;
    this.simpleNumSliders = cconst.simpleNumSliders;
  }
}

export class SimpleScreen extends Screen {
  constructor() {
    super();

    this.keymap = new KeyMap([
      new HotKey("Z", ["CTRL"], () => {
        this.ctx.toolstack.undo(this.ctx);
      }),
      new HotKey("Z", ["CTRL", "SHIFT"], () => {
        this.ctx.toolstack.redo(this.ctx);
      }),
    ])
  }

  init() {
    if (this.ctx.state.startArgs.registerSaveOpenOps) {
      this.keymap.add(new HotKey("S", ["CTRL"], "app.save()"));
      this.keymap.add(new HotKey("O", ["CTRL"], "app.open()"));
    }
  }

  static define() {
    return {
      tagname: "simple-screen-x"
    }
  }

  setCSS() {
    super.setCSS();

    this.style["position"] = UIBase.PositionKey;
    this.style["left"] = this.pos[0] + "px";
    this.style["top"] = this.pos[1] + "px";
  }
}

UIBase.register(SimpleScreen);

export class AppState {
  /** ctxClass is the context class.  It can be either a simple class
   *  or a subclass of the more complex path.ux Context class.  Note that
   *  using Context will avoid subtle undo stack errors caused by the context
   *  changing after a tool is run (this is why Context has a serialization
   *  mechanism).
   *
   *  Path.ux will actually subclass ctxClass and add a few standard methods
   *  and properties, see GetContextClass.*/
  constructor(ctxClass, screenClass = SimpleScreen) {
    this._ctxClass = ctxClass;

    ctxClass = GetContextClass(ctxClass);

    this.startArgs = undefined;
    this.currentFileRef = undefined; //current file path/ref

    this.ctx = new ctxClass(this);
    this.ctx._state = this;
    this.toolstack = new ToolStack();
    this.api = makeAPI(ctxClass);
    this.screenClass = screenClass;
    this.screen = undefined;

    this.fileMagic = "STRT";
    this.fileVersion = [0, 0, 1];
    this._fileExt = "data";
    this._fileExtSet = false;
    this.saveFilesInJSON = false; /* save files in nstructjs-json */

    this.defaultEditorClass = undefined; //if undefined, the first non-menubar editor will be used
  }


  get fileExt() {
    return this._fileExt;
  }

  set fileExt(ext) {
    this._fileExt = ext;
    this._fileExtSet = true;
  }

  /** resets the undo stack */
  reset() {
    this.toolstack.reset();
  }

  /** Create a new file. See this.makeScreen() if you wish
   *  to create a new screen at this time, and this.reset()
   *  if you wish to reset the undo stack*/
  createNewFile() {
    console.warn("appstate.createNewFile: implement me, using default hack");
    let state = new this.constructor(this.ctx._ctxClass);

    state.api = this.api;
    state.ctx = this.ctx;
    state.startArgs = this.startArgs;
    state.saveFilesInJSON = this.saveFilesInJSON;

    state.toolstack = this.toolstack;
    state.toolstack.reset();

    this.screen.unlisten();
    this.screen.remove();

    for (let k in state) {
      this[k] = state[k];
    }

    this.makeScreen();
  }

  /** Serialize the application state. Takes
   *  a list of objects to save (with nstructjs);
   *  Subclasses should override this, like so:
   *
   *  saveFile(args={}) {
   *    let objects = app state;
   *    return super.saveFile(objects, args);
   *  }
   **/
  saveFile(objects, args = {}) {
    args = new FileArgs(Object.assign({
      magic  : this.fileMagic,
      version: this.fileVersion,
      ext    : this.fileExt
    }, args));

    return new Promise((accept, reject) => {
      accept(saveFile(this, args, objects));
    });
  }

  /**
   *  Loads a new file. The default behavior is a
   *  complete state reset (you can control this
   *  with args.reset_toolstack, args.reset_context
   *  and args.doScreen).
   *
   *  As the base class cannot know just what to do
   *  with the loaded data (the objects parameter
   *  passed to saveFile) it is recommended you
   *  override this function like so:
   *
   *  loadFile(data, args) {
   *    return super.loadFile(data, args).then(fileData) => {
   *      // load fileData.objects into appropriate properties
   *      // this is the same objects array originally passed
   *      // to this.saveFile
   *      this.data = fileData.objects;
   *    });
   *  }
   *
   *  @param {ArrayBuffer|JSON|DataView} data
   *  @param {FileArgs} args
   *  */
  loadFile(data, args = {}) {
    return new Promise((accept, reject) => {
      args = new FileArgs(Object.assign({
        magic  : this.fileMagic,
        version: this.fileVersion,
        ext    : this.fileExt
      }, args));

      let ret = loadFile(this, args, data);

      if (args.doScreen) {
        try {
          this.ensureMenuBar();
        } catch (error) {
          console.error(error.stack);
          console.error(error.message);
          console.error("Failed to add menu bar");
        }

        this.screen.completeSetCSS();
        this.screen.completeUpdate();
      }

      accept(ret);
    });
  }

  ensureMenuBar() {
    let screen = this.screen;
    let ok = false;

    for (let sarea of screen.sareas) {
      if (sarea.area instanceof MenuBarEditor) {
        ok = true;
        break;
      }
    }

    if (ok) {
      return;
    }

    if (!Editor.makeMenuBar) {
      /* don't make menu bar if Editor.registerAppMenu hasn't been called */
      return;
    }

    /* ensure screen size is up to date */
    screen.update();

    let sarea = UIBase.createElement("screenarea-x");

    screen.appendChild(sarea);

    let h = 55;
    let min = new Vector2().addScalar(1e17);
    let max = new Vector2().addScalar(-1e17);
    let tmp = new Vector2();

    for (let sarea2 of screen.sareas) {
      if (sarea2 === sarea) {
        continue;
      }

      min.min(sarea2.pos);
      tmp.load(sarea2.pos).add(sarea2.size);
      max.max(tmp);
    }

    let scale = (max[1] - min[1] - h)/(max[1] - min[1]);

    for (let sarea2 of screen.sareas) {
      if (sarea2 === sarea) {
        continue;
      }

      sarea2.pos[1] *= scale;
      sarea2.size[1] *= scale;
      sarea2.pos[1] += h;
    }

    sarea.pos.zero();
    sarea.size[0] = screen.size[0];
    sarea.size[1] = h;

    screen.regenScreenMesh();
    screen.snapScreenVerts();

    sarea.switch_editor(MenuBarEditor);

    screen.solveAreaConstraints();

    screen.completeSetCSS();
    screen.completeUpdate();
  }

  makeScreen() {
    if (this.screen) {
      this.screen.unlisten();
      this.screen.remove();
    }

    let screen = this.screen = UIBase.createElement(this.screenClass.define().tagname);
    let sarea = UIBase.createElement("screenarea-x");

    screen.ctx = this.ctx;
    sarea.ctx = this.ctx;

    document.body.appendChild(screen);

    let cls = this.defaultEditorClass;

    if (!cls) {
      for (let k in areaclasses) {
        cls = areaclasses[k];

        if (cls !== MenuBarEditor) {
          break;
        }
      }
    }

    sarea.switch_editor(cls);
    screen.appendChild(sarea);

    screen._init();
    screen.listen();
    screen.update();
    screen.completeSetCSS();
    screen.completeUpdate();

    if (Editor.makeMenuBar) {
      this.ensureMenuBar();
    }
  }

  start(args = new StartArgs()) {
    let args2 = new StartArgs();

    let methodsCheck = [
      "saveFile", "createNewFile", "loadFile"
    ];

    for (let method of methodsCheck) {
      let m1 = AppState.prototype[method];
      let m2 = this[method];

      if (m1 === m2) {
        console.warn(`Warning: it is recommended to override .${method} when subclassing simple.AppState`);
      }
    }

    document.body.style["touch-action"] = "none";

    registerMenuBarEditor();

    for (let k in args2) {
      if (args[k] === undefined) {
        args[k] = args2[k];
      }
    }

    if (args.registerSaveOpenOps) {
      register();
    }

    if (!args.iconsheet) {
      args.iconsheet = loadDefaultIconSheet();
    }

    this.startArgs = args;

    cconst.loadConstants(args);

    if (args.autoLoadSplineTemplates) {
      initSplineTemplates();
    }

    let sizes = [];
    let images = [];

    for (let size of args.iconSizes) {
      sizes.push([args.iconTileSize, size]);
      images.push(args.iconsheet);
    }

    window.iconsheet = args.iconsheet;


    let iconManager = new IconManager(images, sizes, args.iconsPerRow);
    setIconManager(iconManager);
    setIconMap(args.icons);

    if (args.theme) {
      setTheme(args.theme);
    }

    document.body.style["margin"] = "0px";
    document.body.style["padding"] = "0px";

    if (args.singlePage) {
      document.body.style["overflow"] = "hidden";
    }

    this.makeScreen();

    Object.defineProperty(window, "C", {
      get() {
        return this._appstate.ctx;
      }
    });

    nstructjs.validateStructs();

    if (this.saveFilesInJSON && !this._fileExtSet) {
      this._fileExt = "json";
    }

    if (this._fileExt.startsWith(".")) {
      this._fileExt = this._fileExt.slice(1, this._fileExt.length).trim();
    }
  }
}