Source

frontend/controllers/notes_controller.js

/* This file is part of Ezra Bible App.

   Copyright (C) 2019 - 2023 Ezra Bible App Development Team <contact@ezrabibleapp.net>

   Ezra Bible App is free software: you can redistribute it and/or modify
   it under the terms of the GNU General Public License as published by
   the Free Software Foundation, either version 2 of the License, or
   (at your option) any later version.

   Ezra Bible App is distributed in the hope that it will be useful,
   but WITHOUT ANY WARRANTY; without even the implied warranty of
   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
   GNU General Public License for more details.

   You should have received a copy of the GNU General Public License
   along with Ezra Bible App. See the file LICENSE.
   If not, see <http://www.gnu.org/licenses/>. */

const VerseBoxHelper = require('../helpers/verse_box_helper.js');
const verseBoxHelper = new VerseBoxHelper();
const VerseBox = require('../ui_models/verse_box.js');
const notesHelper = require('../helpers/notes_helper.js');
const i18nHelper = require('../helpers/i18n_helper.js');
const eventController = require('../controllers/event_controller.js');
const verseListController = require('../controllers/verse_list_controller.js');
const PlatformHelper = require('../../lib/platform_helper.js');
const { showErrorDialog, sleep } = require('../helpers/ezra_helper.js');
require('../components/emoji_button_trigger.js');

let CodeMirror = null;
function getCodeMirror() {
  if (CodeMirror == null) {
    CodeMirror = require('codemirror/lib/codemirror.js');
    require("codemirror/addon/edit/continuelist.js");
    require("codemirror/mode/markdown/markdown.js");
    require("codemirror/addon/mode/overlay.js");
    require("codemirror/mode/markdown/markdown.js");
    require("codemirror/mode/gfm/gfm.js");
    require("codemirror/mode/htmlmixed/htmlmixed.js");
  }

  return CodeMirror;
}

/**
 * The NotesController handles all user actions related to note taking.
 * 
 * Like all other controllers it is only initialized once. It is accessible at the
 * global object `app_controller.notes_controller`.
 * 
 * @category Controller
 */
class NotesController {
  constructor() {
    this.theme = this.getCurrentTheme();
    this._platformHelper = new PlatformHelper();
    this._reset();

    eventController.subscribe('on-bible-text-loaded', (tabIndex) => {
      this.initForTab(tabIndex);
    });

    eventController.subscribe('on-tab-selected', () => {
      // When switching tabs we need to end any note editing.
      this.restoreCurrentlyEditedNotes();
    });

    eventController.subscribe('on-body-clicked', () => {
      this.restoreCurrentlyEditedNotes();
    });

    eventController.subscribe('on-theme-changed', (theme) => {
      switch (theme) {
        case 'dark':
          this.setDarkTheme();
          break;

        case 'regular':
          this.setLightTheme();
          break;

        default:
          console.error('Unknown theme ' + theme);
      }
    });
  }

  initForTab(tabIndex = undefined) {
    this._reset();
    var currentVerseListFrame = verseListController.getCurrentVerseListFrame(tabIndex);
    if (currentVerseListFrame == null || currentVerseListFrame.length == 0) {
      return;
    }

    currentVerseListFrame[0].querySelectorAll('.verse-box').forEach(verseBox => {
      const verseNotes = verseBox.querySelector('.verse-notes');

      if (verseNotes != null) {
        verseNotes.classList.remove('visible');

        verseBox.querySelector('.notes-info').addEventListener('mousedown', (e) => {
          this._handleNotesIndicatorClick(e, verseNotes);
        });

        verseNotes.addEventListener('click', (event) => {
          this._handleNotesClick(event);
        });
      }
    });

    const bookNoteBox = currentVerseListFrame[0].querySelector('.book-notes');
    if (bookNoteBox) {
      bookNoteBox.addEventListener('click', (event) => {
        this._handleNotesClick(event);
      });
    }
  }

  async restoreCurrentlyEditedNotes(persist = true) {
    if (persist) {

      if (this._platformHelper.isCordova()) {
        // There is an issue on Android that makes CodeMirror behave weirdly.
        // This leads to the last word of the text being cut off after adding text, unless the user has already typed a space or . character. 
        // See https://github.com/codemirror/codemirror5/issues/5244
        // To work around this issue, we fire the compositionend event before saving the editor content.
        // This will force CodeMirror to properly save the editor lines.

        if (this.currentlyEditedNotes != null) {
          let codeMirrorLines = this.currentlyEditedNotes.querySelector('.CodeMirror-code');
          codeMirrorLines.dispatchEvent(new Event('compositionend'));

          // We need to wait a little bit, so that CodeMirror has a chance to respond.
          await sleep(100);
        }
      }
      
      var success = await this._saveEditorContent();
      if (success == false) {
        return;
      }
    }

    this._renderContent(!persist);
    this._reset();
  }

  _renderContent(original=false) {
    if (this.currentlyEditedNotes != null) {
      var renderedContent = this._getRenderedEditorContent(original);
      this._updateRenderedContent(this.currentlyEditedNotes, renderedContent);
      var currentVerseBox = this._getCurrentVerseBox();

      verseBoxHelper.iterateAndChangeAllDuplicateVerseBoxes(
        currentVerseBox, renderedContent, (changedValue, targetVerseBox) => {

          var targetNotes = null;

          if (targetVerseBox.classList.contains('book-notes')) {
            targetNotes = targetVerseBox;
          } else {
            targetNotes = targetVerseBox.querySelector('.verse-notes');
            this._refreshNotesIndicator(changedValue, targetVerseBox);
          }

          this._updateRenderedContent(targetNotes, changedValue);
        });

      this._resetVerseNoteButtons();
      if (this.currentlyEditedNotes.classList.contains('verse-notes-empty')) {
        this.currentlyEditedNotes.classList.remove('visible');
      }
    }
  }

  _reset() {
    this.currentVerseReferenceId = null;
    this.currentlyEditedNotes = null;
    this.currentEditor = null;
  }

  async _handleNotesClick(event) {
    app_controller.hideAllMenus();

    if (event.target.nodeName == 'A') {
      // If the user is clicking a link within the note ('a' element)
      // we simply return and let the platform handle the default action
      // (Link will be opened in the default browser)
      return;
    }
    event.stopPropagation();

    var verseReferenceId = null;
    var verseNotesBox = event.target.closest('.verse-notes');

    if (verseNotesBox.classList.contains('book-notes')) {
      verseReferenceId = verseNotesBox.getAttribute('verse-reference-id');
    } else {
      var verseBox = verseNotesBox.closest('.verse-box');
      verseReferenceId = new VerseBox(verseBox).getVerseReferenceId();
    }

    if (verseReferenceId != this.currentVerseReferenceId) {
      await this.restoreCurrentlyEditedNotes();
      this.currentVerseReferenceId = verseReferenceId;
      this.currentlyEditedNotes = verseNotesBox;
      this.currentlyEditedNotes.classList.remove('verse-notes-empty');
      this._createEditor(this.currentlyEditedNotes);
      this._setupVerseNoteButtons();
    }
  }

  _handleNotesIndicatorClick(e, verseNotes) {
    e.stopPropagation();
    e.target.closest('.notes-info').classList.toggle('active');
    this._showAndClickVerseNotes(verseNotes);
  }

  editVerseNotesForCurrentlySelectedVerse() {
    const selectedVerseBoxes = app_controller.verse_selection.getSelectedVerseBoxes();
    const firstVerseBox = selectedVerseBoxes[0];

    if (firstVerseBox != null) {
      const verseNotes = firstVerseBox.querySelector('.verse-notes');
      this._showAndClickVerseNotes(verseNotes);
    }
  }

  deleteVerseNotesForCurrentlySelectedVerse() {
    const selectedVerseBoxes = app_controller.verse_selection.getSelectedVerseBoxes();
    const firstVerseBox = selectedVerseBoxes[0];

    if (firstVerseBox != null) {
      const verseNotes = firstVerseBox.querySelector('.verse-notes');
      this._deleteVerseNotes(verseNotes);
    }
  }

  _showAndClickVerseNotes(verseNotes) {
    verseNotes.classList.toggle('visible');

    if (verseNotes.classList.contains('verse-notes-empty')) {
      verseNotes.dispatchEvent(new MouseEvent('click'));
    }
  }

  async _deleteVerseNotes(verseNotes) {
    this.currentVerseReferenceId = verseNotes.closest('.verse-box').getAttribute('verse-reference-id');
    this.currentlyEditedNotes = verseNotes;
    await this._saveEditorContent("");
    this._renderContent(true);
    this._reset();
  }

  _getCurrentVerseBox() {
    if (this.currentVerseReferenceId == null) {
      return null;
    }

    var currentVerseListFrame = verseListController.getCurrentVerseListFrame();
    return currentVerseListFrame[0].querySelector('.verse-reference-id-' + this.currentVerseReferenceId);
  }

  _refreshNotesIndicator(noteValue, verseBox) {
    if (verseBox == null) {
      return;
    }

    var notesInfo = verseBox.querySelector('.notes-info');

    if (notesInfo != null) {
      if (noteValue != '') {
        notesInfo.classList.add('visible');
      } else {
        notesInfo.classList.remove('visible');
      }
      notesInfo.setAttribute('title', notesHelper.getTooltipText(noteValue));
    }
  }

  _updateActiveIndicator(noteValue) {
    var currentVerseBox = this._getCurrentVerseBox();
    if (currentVerseBox == null) {
      return;
    }

    var notesInfo = currentVerseBox.querySelector('.notes-info');

    if (notesInfo != null && noteValue == '') {
      notesInfo.classList.remove('active');
    }
  }

  async _saveEditorContent(newNoteValue=null) {
    var currentVerseBox = this._getCurrentVerseBox();

    if (this.currentlyEditedNotes != null && currentVerseBox != null) {
      if (newNoteValue == null) {
        newNoteValue = this.currentEditor.getValue();
      }

      var previousNoteValue = this.currentlyEditedNotes.getAttribute('notes-content');

      this._updateActiveIndicator(newNoteValue);

      if (newNoteValue != previousNoteValue) {
        newNoteValue = newNoteValue.trim();
        var currentVerseObject = new VerseBox(currentVerseBox).getVerseObject();
        var translationId = app_controller.tab_controller.getTab().getBibleTranslationId();

        const swordModuleHelper = require('../helpers/sword_module_helper.js');
        var versification = await swordModuleHelper.getVersification(translationId);

        var result = await ipcDb.persistNote(newNoteValue, currentVerseObject, versification);

        if (result.success == false) {
          var message = `The note could not be saved.<br>
                        An unexpected database error occurred:<br><br>
                        ${result.exception}<br><br>
                        Please restart the app.`;

          await showErrorDialog('Database Error', message);
          this._focusEditor();
          return false;
        }

        this.currentlyEditedNotes.setAttribute('notes-content', newNoteValue);
        this._refreshNotesIndicator(newNoteValue, currentVerseBox);

        var note = result.dbObject;

        if (note != undefined) {
          var updatedTimestamp = null;

          if (newNoteValue == "") {
            updatedTimestamp = "";
          } else {
            updatedTimestamp = note.updatedAt;
          }

          this._updateNoteDate(currentVerseBox, updatedTimestamp);

          verseBoxHelper.iterateAndChangeAllDuplicateVerseBoxes(
            currentVerseBox, { noteValue: newNoteValue, timestamp: updatedTimestamp }, (changedValue, targetVerseBox) => {

              var currentNotes = null;

              if (targetVerseBox.classList.contains('book-notes')) {
                currentNotes = targetVerseBox;
              } else {
                currentNotes = targetVerseBox.querySelector('.verse-notes');
              }

              currentNotes.setAttribute('notes-content', changedValue.noteValue);
              this._updateNoteDate(targetVerseBox, changedValue.timestamp);
            }
          );
        }
      }

      if (newNoteValue != "") {
        await eventController.publishAsync('on-note-created');
      } else {
        await eventController.publishAsync('on-note-deleted');
      }
    }

    return true;
  }

  _updateNoteDate(verseBox, dbTimestamp) {
    var localizedTimestamp = "";

    if (dbTimestamp != "") {
      localizedTimestamp = i18nHelper.getLocalizedDate(dbTimestamp);
    }

    verseBox.querySelector('.verse-notes-timestamp').innerText = localizedTimestamp;
  }

  _getRenderedEditorContent(original = false) {
    const { marked } = require("marked");

    var content = null;

    if (original) {
      content = this._getNotesElementContent(this.currentlyEditedNotes);
    } else {
      content = this.currentEditor.getValue();
    }

    var renderedContent = "";

    if (content != "") {
      renderedContent = marked.parse(content);
    }

    return renderedContent;
  }

  _updateRenderedContent(notesElement, renderedContent) {
    notesElement.style.removeProperty('height');
    var verseNotesText = notesElement.querySelector('.verse-notes-text');
    verseNotesText.classList.remove('edited');
    verseNotesText.innerHTML = renderedContent;
 
    if (renderedContent == '') {
      notesElement.classList.add('verse-notes-empty');
    } else {
      notesElement.classList.remove('verse-notes-empty');
    }
  }

  _resetVerseNoteButtons() {
    var $verseNotesButtons = $(this.currentlyEditedNotes).find('.verse-notes-buttons');
    $verseNotesButtons.find('a').unbind();
    $verseNotesButtons.hide();
  }

  _setupVerseNoteButtons() {
    var $verseNotesButtons = $(this.currentlyEditedNotes).find('.verse-notes-buttons');

    $verseNotesButtons.find('a').bind('click', (event) => {
      event.preventDefault();
      event.stopPropagation();

      if (event.currentTarget.className == 'save-note') {

        this.restoreCurrentlyEditedNotes();

      } else if (event.currentTarget.className == 'cancel-edit') {

        this.restoreCurrentlyEditedNotes(false);

      }
    });

    $verseNotesButtons.show();
  }

  _getNotesElementContent(notesElement) {
    var notesContent = "";
    if (notesElement.hasAttribute('notes-content')) {
      notesContent = notesElement.getAttribute('notes-content');
    }

    return notesContent;
  }

  _htmlToElement(html) {
    var template = document.createElement('template');
    html = html.trim(); // Never return a text node of whitespace as the result
    template.innerHTML = html;
    return template.content;
  }

  _createEditor(notesElement) {
    var CodeMirror = getCodeMirror();
    CodeMirror.commands.save = () => this.restoreCurrentlyEditedNotes();

    var notesElementText = notesElement.querySelector('.verse-notes-text');
    notesElementText.classList.add('edited');
    notesElementText.innerHTML = '';

    // FIXME: have template to be defined once and insert it with cloneNode(true)
    var textAreaTemplate = this._htmlToElement('<textarea class="editor"></textarea><emoji-button-trigger class="btn-picker"></emoji-button-trigger>');
    notesElementText.appendChild(textAreaTemplate); 

    var textAreaElement = notesElementText.querySelector('.editor');
    textAreaElement.value = this._getNotesElementContent(notesElement);

    var editor = CodeMirror.fromTextArea(textAreaElement, {
      mode: 'gfm',
      autoCloseBrackets: true,
      lineNumbers: false,
      lineWrapping: true,
      viewportMargin: Infinity,
      autofocus: true,
      extraKeys: { 
        "Enter": "newlineAndIndentContinueMarkdownList",
        "Ctrl-Enter": "save",
        "Cmd-Enter": "save",
        "Esc": () => { this.restoreCurrentlyEditedNotes(false); }
      },
      theme: this.theme
    });

    this.currentEditor = editor;
    this._focusEditor(true);
    notesElementText.querySelector('.btn-picker').attachEditor(editor);
  }

  _focusEditor(moveCursorToEnd=false) {
    setTimeout(() => {
      if (this.currentEditor != null) {
        this.currentEditor.refresh();
        this.currentEditor.getInputField().focus();

        if (moveCursorToEnd) {
          this.currentEditor.execCommand('goDocEnd');
        }
      }
    }, 50);
  }

  getCurrentTheme() {
    var theme = 'default';
    if (app_controller.optionsMenu._nightModeOption && app_controller.optionsMenu._nightModeOption.isChecked) {
      theme = 'mbo';
    }

    return theme;
  }

  setLightTheme() {
    this.theme = 'default';
    this._refreshTheme();
  }

  setDarkTheme() {
    this.theme = 'mbo';
    this._refreshTheme();
  }

  _refreshTheme() {
    if (this.currentEditor != null) {
      this.currentEditor.setOption("theme", this.theme);
      this._focusEditor();
    }
  }
}

module.exports = NotesController;