Source: index.js

/** This module implements the _document ready_ event handler
    for [jQuery](https://en.wikipedia.org/wiki/JQuery)
    to control the {@link module:Practice~Model practice page model}, i.e.,
    to edit and run examples.

  @module GUI
  @author © 2023 Axel T. Schreiner <axel@schreiner-family.net>
  @version 2024-10-10
*/

import * as Practice from './practice.js';

/** The _document ready_ event handler.
*/
function browse () {
  // reference: code to detect how the page was entered
  // const entries = performance.getEntriesByType("navigation");
  // entries.forEach((entry) => {
  //   switch (entry.type) {
  //   case "navigate":     console.log(`${entry.name} was reached from address!`); break;
  //   case "reload":       console.log(`${entry.name} was reloaded!`); break;
  //   case "back_forward": console.log(`${entry.name} was reached from history!`); break;
  //   default:             console.log(`${entry.name} was reached by ${entry.type}!`);
  //   }
  // });
  
  // HTML structure               state variable(s)
  // form class='ebnf|stack|bnf'
  //   id-grammar                 model.grammar                   nested into .frame with .label
  //   id-tokens                  model.tokens                    nested into .frame with .label
  //   id-program                 model.program                   nested into .frame with .label
  //   id-actions                 model.actions                   nested into .frame with .label
  //   id-output                                                  nested into .frame with id-book label
  //   id-mode                    model.mode                      toggles ebnf|stack|bnf
  //   id-greedy    id-error      model.greedy    model.error
  //   id-tShallow  id-dSets      model.tShallow  model.dSets
  //   id-tDeep     id-dStates    model.tDeep     model.dStates
  //   id-tFollow                 model.tFollow
  //   id-new                     model.doNew()
  //   actions                    model.actionsArea               nested into .frame with .label
  //   id-scan                    model.doScan()
  //   id-tLookahead              model.tLookahead
  //   id-tParser                 model.tParser
  //   id-tActions                model.tActions
  //   id-tNoargs                 model.tNoargs
  //   id-build                   model.build
  //   id-parse                   model.doParse()
  //   id-run                     model.doRun()
  //   id-1                       model.doStep(1)
  //   id-10                      model.doStep(10)
  //   id-100                     model.doStep(100)

  // set book button
  {
    let m, book = 'doc/tutorial-a-webpage.html';
    
    if (m = /eg=[01][0-9]\/[012][0-9]/.exec(location.search))
      book = 'doc/tutorial-' + {
          '02': '02-grammars',
          '03': '03-scanner',
          '04': '04-parser',
          '05': '05-lists',
          '06': '06-compile',
          '07': '07-features',
          '08': '08-functions',
          '09': '09-bootstrap',
          '10': '10-bottom-up',
          '11': '11-trees'
        }[m[0].substring(3, 5)] + '.html?' + m[0];
    
    else if (m = /eg=[a-z_]+/.exec(location.search))
      book = {
        interpret: "doc/tutorial-06-compile.html#immediate-evaluation",
        compile: "doc/tutorial-06-compile.html#functional-evaluation",
        postfix: "doc/tutorial-06-compile.html#stack-evaluation",
        stack: "doc/tutorial-06-compile.html#stack-evaluation",
        little: "doc/tutorial-06-compile.html#control-structures",
        little_fn: "doc/tutorial-06-compile.html#functional-programming",
        typing: "doc/tutorial-07-features.html#type-checking-by-interpretation",
        recursion: "doc/tutorial-07-features.html#functions",
        functions: "doc/tutorial-07-features.html#local-variables",
        scopes: "doc/tutorial-07-features.html#block-scopes",
        nesting: "doc/tutorial-07-features.html#nested-functions",
        first_glob: "doc/tutorial-08-functions.html#global-first-order-functions",
        fn_parameter: "doc/tutorial-08-functions.html#functions-as-argument-values",
        first: "doc/tutorial-08-functions.html#nested-first-order-functions",
        curry: "doc/tutorial-08-functions.html#nested-first-order-functions",
        compose: "doc/tutorial-08-functions.html#nested-first-order-functions",
        bootstrap: "doc/tutorial-09-bootstrap.html#bootstrap-example",
        extend: "doc/tutorial-09-bootstrap.html#extending-the-grammars'-grammar"
      }[m[0].substring(3)];
          
    $('#id-book').attr('href', book);
  }
  
  // create model, creates and sets global variables
  const model = new Practice.Model(window);

  // set window.newOutput: (re)start output
  newOutput = (...s) => $('#id-output').val(s.join(' '));
  
  // set window.puts: append to output
  puts = (...s) => {
    const output = $('#id-output'),
      head = output.val(),
      nl = head.length && ! head.endsWith('\n') ? '\n' : '';
    output.val(head + nl + s.join(' '));
    output.scrollTop(output.prop("scrollHeight"));
  };

  // manage local storage, if possible
  $(document).on('visibilitychange', () => {
    try {
      localStorage.setItem('EBNF/state', [
          '%% grammar\n' + model.grammar.trim() + '\n',
          '%% tokens\n' + model.tokens.trim() + '\n',
          '%% actions\n' + model.actions.trim() + '\n',
          '%% program\n' + model.program.trim() + '\n'
        ].join('')
      );
    } catch (e) { console.error('localStorage.setItem failed: ', e.message); }          
  });
  const getState = () => {
    try {
      return localStorage.getItem('EBNF/state');
    } catch (e) {
      console.error('localStorage.getItem failed: ', e.message);
      return null;
    }          
  };
  const removeState = () => {
    try {
      localStorage.removeItem('EBNF/state');
    } catch (e) { console.error('localStorage.removeItem failed: ', e.message); }          
  };
  
  // run user interface from text, if any
  const ui = function (text) {
    
    // no text? use or clear local storage
    if (typeof text != 'string' || !text.length) {
      // failed search?
      if (document.location.search.length > 1)
        removeState();
      else
        text = getState();
    }
    
    // load from text, if any
    if (typeof text == 'string' && text.length) {
      document.title = 'Example ' + stem.replace(/^0/, '');      
      
      // clear and load all text areas, tell model
      $('textarea').val('');
      text.split(/%%/).forEach(part => {
        try {
          const id = part.match(/\s+(grammar|tokens|program|actions|output)\s+/)[1];
          part = part.replace(/^[^\n]*\n/, '').replace(/\n*$/, '');
          switch (id) {
          case 'grammar': $('#id-grammar').val(model.grammar = part); break;
          case 'tokens':  $('#id-tokens').val(model.tokens = part); break;
          case 'program': $('#id-program').val(model.program = part); break;
          case 'actions': $('#id-actions').val(model.actions = part); break;
          case 'output':  $('#id-output').val(part);
          }
        } catch (e) { }
      });      
    }
    
    // capture initial heights (?? not good if window is resized)
    const vh = { };
    $('.frame textarea').each(function () { vh[$(this).attr('id')] = $(this).height(); })

    // stack machine test
    const isStackMachine = () => typeof run == 'function' && run.length == 2;
    
    // maintain text area contents and set buttons to reflect contents
    const setButtons  = function () {
            
      // deactivate all buttons
      $('.button').removeClass('ok');
      $('#id-1, #id-10, #id-100').addClass('hidden');

      // toggle-button clicks - toggle 'on' and flag in model
      $('.button.toggle').each(function () {
        const name = $(this).attr('id').substring(3);
        console.assert(typeof model[name] == 'boolean', name, 'is not boolean');
        if (model[name]) $(this).addClass('on'); else $(this).removeClass('on');
      });

      // mode - always ok
      $('#id-mode').addClass('ok');

      // new - requires text in grammar
      if (model.grammar.length) $('#id-new').addClass('ok');
      
      switch (model.mode) {
      case 'ebnf':
        // greedy shallow deep follow - require text in grammar
        if (model.grammar.length)
          $('#id-greedy, #id-tShallow, #id-tDeep, #id-tFollow').addClass('ok');
        break;
        
      case 'stack':
        // error sets states - require text in grammar
        if (model.grammar.length) $('#id-error').addClass('ok');
      case 'bnf':
        // sets states - require text in grammar
        if (model.grammar.length) $('#id-dSets, #id-dStates').addClass('ok');
      }
      
      // scan lookahead parser actions noargs build parse - require represented grammar
      if (g != null) {
        $('#id-scan, #id-tLookahead, #id-tParser, #id-tActions, #id-tNoargs, #id-parse').addClass('ok');
        if (model.mode == 'bnf') $('#id-build').addClass('ok');
      }
      
      // run 1 10 100 - require represented grammar, run, and possibly stack machine
      if (g != null && typeof run == 'function') {
        $('#id-run').addClass('ok');
        if (isStackMachine()) $('#id-1, #id-10, #id-100').addClass('ok').removeClass('hidden');
      }
    }
    setButtons();

    // deactivate all buttons if textarea gets edited
    $('textarea').on('focus', () => {
      $('.button').removeClass('ok');
      // $('#id-1, #id-10, #id-100').addClass('hidden');
    });
    // (re-)activate once focus is out of textarea
    $('textarea').on('blur', setButtons);

    // mode click - next mode, need to recreate window.g and window.run
    $('#id-mode').click(() => {
      $('form').removeClass(model.mode);
      model.mode = model.mode == 'ebnf' ? 'stack' : model.mode == 'stack' ? 'bnf' : 'ebnf';
      $('form').addClass(model.mode);
      newOutput('');
      setButtons();
    });

    // act on change
    $('#id-grammar').change(function () {
      model.grammar = $(this).val().trim();
      newOutput('');
      setButtons();
    });   // need to recreate g and run
    $('#id-tokens').change(function () {
      model.tokens = $(this).val().trim();
      newOutput('');
      setButtons();
    });    // need to recreate g and run
    $('#id-actions').change(function () {
      model.actions = $(this).val().trim();
      newOutput('');
      setButtons();
    });   // need to recreate run
    $('#id-program').change(function () {
      model.program = $(this).val().trim();
      newOutput('');
      setButtons();
    });   // need to recreate run
        
    // click/shift-click on label to emphasize or default textarea layout
    $('#id-grammar, #id-tokens, #id-actions, #id-program').prev('.label').
      click(function (event) {
        if (event.shiftKey)
          $('.frame textarea').each(function () { $(this).height(vh[$(this).attr('id')]); });
        else if (!(event.metaKey || event.altKey)) {
          const area = $(this).next('textarea'), id = area.attr('id');
          let height = 0;
          $('#id-grammar, #id-tokens, #id-actions, #id-program').each(function () {
            height += $(this).height() - 30; $(this).height(30);
          });
          area.height(height + 30);
        }
      });

      // alt-click handler to open popup with highlighted (non-empty) text location
    const highlighter = (name, js) =>
      function (event) {
        if (!(event.metaKey || event.altKey)) return;
        const text = $(this).next('textarea').val();
        if (!text.length) return;
        const prefix = location.href.replace(/EBNF.*/, 'EBNF/doc/');
        const html = `<!DOCTYPE html>
          <html>
            <head>
              <title> ${name} </title>
                <link rel="stylesheet" type="text/css"
                  href="${prefix}styles/sunlight.default.css">
                <script src="${prefix}sunlight-all-min.js"></script>` +
                (js ? `<script src="${prefix}sunlight.javascript.js"></script>` : ``) +
            `</head>
            <body>
              <h3> ${name} </h3>
              <pre id="code" class="sunlight-highlight-${js ? 'javascript' : 'plaintext'}">${text}</pre>
              </div>
              <script> 
                new Sunlight.Highlighter({
                  lineNumbers: true,
                  tabWidth: 2 }).highlightNode(document.getElementById("code"))
              </script>
            <body>
          </html>`;
        const winUrl = URL.createObjectURL(new Blob([html], { type: "text/html" }));
        window.open(winUrl, 'source', 'popup,width=800,height=400,screenX=200,screenY=200');
      };

    // alt-click on label to syntax-color in popup
    $('#id-grammar').prev('.label').click(highlighter('grammar', false));
    $('#id-program').prev('.label').click(highlighter('program', false));
    $('#id-tokens').prev('.label').click(highlighter('tokens', true));
    $('#id-actions').prev('.label').click(highlighter('actions', true));
    $('#id-output').prev('.label').click(highlighter('output', false));
    
    // toggle-button clicks - toggle 'on' and flag in model
    $('.button.toggle').click(function () {
      if ($(this).hasClass('ok')) {
        const name = $(this).attr('id').substring(3);
        console.assert(typeof model[name] == 'boolean', name, 'is not boolean');
        model[name] = !model[name];
        $(this).toggleClass('on');
      }
    });

    // new Grammar
    $('#id-new').click(function () {
      if ($(this).hasClass('ok')) {
        model.doNew();
        setButtons();
      }
    });

    // scanner
    $('#id-scan').click(function () {
      if ($(this).hasClass('ok'))
        model.doScan();
    });

    // parser/compiler
    $('#id-parse').click(function () {
      if ($(this).hasClass('ok')) {
        model.doParse();
        setButtons();
      }
    });

    // run
    $('#id-run').click(function () {
      if ($(this).hasClass('ok'))
        model.doRun();
    });

    // 1 10 100
    $('#id-1, #id-10, #id-100').click(function () {
      if ($(this).hasClass('ok'))
        model.doStep(parseInt($(this).attr('id').substring(3), 10));
    });
  }

  // determine search parameters, if any 
  const params = new URLSearchParams(document.location.search),
    stem = params.get('eg');
  
  // set form class=' ebnf|stack|bnf ' from mode
  switch (model.mode = params.get('mode')) {
  default:
    model.mode = 'ebnf';
  case 'stack':
  case 'bnf':
    $('form').removeClass('ebnf stack bnf').addClass(model.mode);
  }
  
  // delegate to user interface
  if (stem && /^[/0-9a-zA-Z_]+$/.test(stem))
    $.get('eg/' + stem + '.eg', ui, 'text').fail(ui);
  else
    ui();
}
$(browse);

export { browse };