OurBigBook
editor.js
class OurbigbookEditor {
  constructor(root_elem, initial_content, monaco, ourbigbook, ourbigbook_runtime, options) {
    this.ourbigbook = ourbigbook
    this.ourbigbook_runtime = ourbigbook_runtime
    this.modified = false
    this.monaco = monaco
    this.decorations = []
    if (options === undefined) {
      options = {}
    }
    if (!('convertOptions' in options)) {
      options.convertOptions = {}
    }
    if (!('body_only' in options)) {
      options.convertOptions.body_only = true
    }
    if (!('production' in options)) {
      options.production = true
    }
    if (!('modifyEditorInput' in options)) {
      options.modifyEditorInput = (old) => { return { offset: 0, new: old } }
    }
    this.modifyEditorInput = options.modifyEditorInput
    if (!('onDidChangeModelContentCallback' in options)) {
      options.onDidChangeModelContentCallback = (editor, event) => {}
    }
    if (!('postBuildCallback' in options)) {
      options.postBuildCallback = (extra_returns) => {}
    }
    if (!('scrollPreviewToSourceLineCallback' in options)) {
      options.scrollPreviewToSourceLineCallback = (opts) => {}
    }
    this.options = options

    // Create input and output elems.
    const input_elem = document.createElement('div');
    input_elem.classList.add('input');
    const output_elem = document.createElement('div');
    this.output_elem = output_elem
    output_elem.classList.add('output');
    output_elem.classList.add('ourbigbook');
    const errors_elem = document.createElement('div');
    this.errors_elem = errors_elem
    errors_elem.classList.add('errors');
    root_elem.innerHTML = '';
    root_elem.appendChild(input_elem);
    root_elem.appendChild(output_elem);
    root_elem.appendChild(errors_elem);

    monaco.languages.register({ id: 'ourbigbook' });
    // TODO replace with our own tokenizer output:
    // https://github.com/ourbigbook/ourbigbook/issues/106
    monaco.languages.setMonarchTokensProvider('ourbigbook', {
      macroName: /[a-zA-Z0-9_]+/,
      tokenizer: {
        root: [
          [/\\@macroName/, 'macro'],
          [/\\./, 'escape'],

          // Positional arguments.
          [/\[\[\[/, 'literalStart', 'argumentDelimLiteral2'],
          [/\[\[/, 'literalStart', 'argumentDelimLiteral'],
          [/[[\]}]/, 'argumentDelim'],

          // Named arguments.
          [/{{/, 'argumentDelim', 'argumentNameLiteral'],
          [/{/, 'argumentDelim', 'argumentName'],

          [/\$\$\$/, 'literalStart', 'insaneMath3'],
          [/\$\$/, 'literalStart', 'insaneMath2'],
          [/\$/, 'literalStart', 'insaneMath'],

          [/````/, 'literalStart', 'insaneCode4'],
          [/```/, 'literalStart', 'insaneCode3'],
          [/``/, 'literalStart', 'insaneCode2'],
          [/`/, 'literalStart', 'insaneCode'],

          [/^=+ .*/, 'insaneHeader'],

          // Insane list.
          [/^(  )*\*( |$)/, 'argumentDelim'],
          // Insane table.
          [/^(  )*\|\|( |$)/, 'argumentDelim'],
          [/^(  )*\|( |$)/, 'argumentDelim'],
        ],
        argumentDelimLiteral: [
          [/\]\]/, 'literalStart', '@pop'],
          [/./, 'literalInside'],
        ],
        argumentDelimLiteral2: [
          [/\]\]\]/, 'literalStart', '@pop'],
          [/./, 'literalInside'],
        ],
        argumentName: [
          [/@macroName/, 'argumentName'],
          [/=/, 'argumentDelim', '@pop'],
          [/}/, 'argumentDelim', '@pop'],
        ],
        // TODO find a way to make content literalInside.
        argumentNameLiteral: [
          [/@macroName/, 'argumentName'],
          [/=/, 'argumentDelim', '@pop'],
          [/}}/, 'argumentDelim', '@pop'],
        ],
        insaneCode: [
          [/`/, 'literalStart', '@pop'],
          [/./, 'literalInside'],
        ],
        insaneCode2: [
          [/``/, 'literalStart', '@pop'],
          [/./, 'literalInside'],
        ],
        insaneCode3: [
          [/```/, 'literalStart', '@pop'],
          [/./, 'literalInside'],
        ],
        insaneCode4: [
          [/````/, 'literalStart', '@pop'],
          [/./, 'literalInside'],
        ],
        insaneMath: [
          [/\$/, 'literalStart', '@pop'],
          [/./, 'literalInside'],
        ],
        insaneMath2: [
          [/\$\$/, 'literalStart', '@pop'],
          [/./, 'literalInside'],
        ],
        insaneMath3: [
          [/\$\$\$/, 'literalStart', '@pop'],
          [/./, 'literalInside'],
        ],
      }
    });
    monaco.editor.defineTheme('vs-dark-ourbigbook', {
      base: 'vs-dark',
      inherit: true,
      rules: [
        { token: 'argumentDelim', foreground: 'FFFFFF', fontStyle: 'bold' },
        { token: 'argumentName', foreground: 'FFAAFF', fontStyle: 'bold'},
        { token: 'insaneHeader', foreground: 'FFFF00', fontStyle: 'bold' },
        { token: 'literalStart', foreground: 'FFFF00', fontStyle: 'bold' },
        { token: 'literalInside', foreground: 'FFFF88' },
        { token: 'macro', foreground: 'FF8800', fontStyle: 'bold' },
      ],
      // This option became mandatory after some update, even if empty, otherwise:
      // Cannot read properties of undefined (reading 'editor.foreground')
      colors: {},
    });
    const editor = monaco.editor.create(
      input_elem,
      {
        // https://stackoverflow.com/questions/47017753/monaco-editor-dynamically-resizable
        automaticLayout: true,
        folding: false,
        language: 'ourbigbook',
        minimap: {enabled: false},
        scrollBeyondLastLine: false,
        theme: 'vs-dark-ourbigbook',
        wordWrap: 'on',
        value: initial_content,
      }
    );
    this.editor = editor
    if (options.initialLine) {
      // https://stackoverflow.com/questions/45123386/scroll-to-line-in-monaco-editor
      editor.revealLineInCenter(options.initialLine)
    }
    editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, function() {
      options.handleSubmit();
    })
    editor.onDidChangeModelContent(async (e) => {
      options.onDidChangeModelContentCallback(editor, e)
      this.modified = true
      await this.convertInput()
    });
    editor.onDidScrollChange(e => {
      const range = editor.getVisibleRanges()[0];
      const lineNumber = range.startLineNumber
      // So that the title bar will show on dynamic website
      // when user scrolls to line 1.
      const block = lineNumber === 1 ? 'center' : 'start'
      this.scrollPreviewToSourceLine(lineNumber, block);
    });
    editor.onDidChangeCursorPosition(e => {
      this.scrollPreviewToSourceLine(e.position.lineNumber, 'center');
    });
    this.convertInput();
    this.ourbigbook_runtime(this.output_elem)

    this.beforeunload = (e) => {
      if (this.modified) {
        e.preventDefault()
        return e.returnValue = this.modified;
      }
    }
    window.addEventListener('beforeunload', this.beforeunload)
  }

  // https://stackoverflow.com/questions/7317273/warn-user-before-leaving-web-page-with-unsaved-changes

  async convertInput() {
    let extra_returns = {};
    let ok = true
    try {
      this.modifyEditorInputRet = this.modifyEditorInput(this.getValue())
      this.output_elem.innerHTML = await this.ourbigbook.convert(
        this.modifyEditorInputRet.new,
        this.options.convertOptions,
        extra_returns
      );
    } catch(e) {
      // TODO clearly notify user on UI that they found a Ourbigbook crash bug for the current input.
      console.error(e);
      ok = false
      if (!this.options.production) {
        // This shows proper stack traces in the console unlike what is shown on browser for some reason.
        //throw e
      }
    }
    if (ok) {
      // Rebind to newly generated elements.
      this.ourbigbook_runtime(this.output_elem);
      this.line_to_id = extra_returns.context.line_to_id;

      // Error handling.
      this.errors_elem.innerHTML = ''
      if (extra_returns.errors.length) {
        this.errors_elem.classList.add('has-error')
      } else {
        this.errors_elem.classList.remove('has-error')
      }
      for (const e of extra_returns.errors) {
        const error_elem = document.createElement('div');
        error_elem.classList.add('error')
        const a = document.createElement('a');
        a.classList.add('loc')
        const line = e.source_location.line - this.modifyEditorInputRet.offset
        a.innerHTML = `Line ${line}`
        a.addEventListener('click', (e) => { this.editor.revealLineNearTop(line) })
        error_elem.appendChild(a)
        error_elem.appendChild(document.createTextNode(`: ${e.message}`))
        this.errors_elem.appendChild(error_elem)
      }
      this.decorations = this.editor.deltaDecorations(
        this.decorations,
        extra_returns.errors.map(e => {
          const line = e.source_location.line - this.modifyEditorInputRet.offset
          return {
            range: new this.monaco.Range(line, 1, line, 1),
            options: {
              isWholeLine: true,
              linesDecorationsClassName: 'errorDecoration'
            }
          }
        })
      );

      this.options.postBuildCallback(extra_returns)
    }
  }

  dispose() {
    window.removeEventListener('beforeunload', this.beforeunload);
    this.editor.dispose()
  }

  getValue() {
    // TODO use model.setEOL(monaco.editor.EndOfLineSequence.LF) instead of the \r\n.
    // Haven't done yet because lazy to boot into Windows:
    // https://stackoverflow.com/questions/56525822/how-to-set-eol-to-lf-for-windows-so-that-api-gets-value-with-n-not-r-n/74624712#74624712
    // https://github.com/microsoft/monaco-editor/issues/3440
    let ret = this.editor.getValue().replaceAll('\r\n', '\n').replace(/^(\n+)?$/, '')
    if (ret.length) {
      ret = ret.replace(/(\n+)?$/, '\n')
    }
    return ret
  }

  scrollPreviewToSourceLine(line_number, block) {
    const line_number_orig = line_number
    line_number += this.modifyEditorInputRet.offset
    if (block === undefined) {
      block = 'center';
    }
    if (this.line_to_id) {
      // Can fail in case of conversion errors.
      const id = this.line_to_id(line_number);
      if (
        // Possible on empty document.
        id !== ''
      ) {
        // TODO this would be awesome to make the element being targeted red,
        // but it loses editor focus  on new paragraphs (e.g. double newline,
        // making it unusable.
        // window.location.hash = id;
        const elem = document.getElementById(id)
        if (elem) {
          if (line_number_orig === 1) {
            // To show the h1 toplevel.
            this.output_elem.scrollTop = 0
          } else {
            // https://stackoverflow.com/questions/45408920/plain-javascript-scrollintoview-inside-div
            // https://stackoverflow.com/questions/5389527/how-to-get-offset-relative-to-a-specific-parent
            // https://stackoverflow.com/questions/37137450/scroll-all-nested-scrollbars-to-bring-an-html-element-into-view
            function scrollParentToChild(parent, child) {
              const parentRect = parent.getBoundingClientRect();
              const childRect = child.getBoundingClientRect();
              const scrollTop = childRect.top - parentRect.top;
              parent.scrollTop += scrollTop;
            }
            scrollParentToChild(this.output_elem, elem);
          }
        } else {
          console.error(`could not find ID for line ${line_number}: ${id}`);
        }
      };
    }
    this.options.scrollPreviewToSourceLineCallback({ ourbigbook_editor: this, line_number, line_number_orig })
  }

  async setModifyEditorInput(modifyEditorInput) {
    this.modifyEditorInput = modifyEditorInput
    await this.convertInput()
  }
}


if (typeof exports !== 'undefined') {
  exports.OurbigbookEditor = OurbigbookEditor;
}