OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / lib / views / editor-ui.js

382 lines · javascript ref:
/**
 * Editor Page — SSR shell for note editing
 *
 * Loads the appropriate editor (TOUI markdown, jSpreadsheet, FullCalendar)
 * via client-side JavaScript. The server renders the page shell with
 * toolbar, title/tags inputs, and the editor container.
 */

import { renderPage } from '../page-layout.js'

function esc (s) {
  return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}

/**
 * Render the editor page
 *
 * @param {Object} notebook - Notebook metadata
 * @param {Object|null} note - Existing note (null for new note)
 * @param {Object} opts - { pathPrefix, noteType }
 * @returns {string} Full HTML page
 */
export function renderEditorPage (notebook, note, opts = {}) {
  const pathPrefix = opts.pathPrefix || ''
  const nbSlug = notebook.slug
  const isNew = !note
  const noteType = (note && note.type) || opts.noteType || 'markdown'
  const title = isNew ? '' : (note.title || '')
  const tags = isNew ? '' : (note.tags || []).join(', ')
  const noteId = note ? note.id : null

  // Serialize note content for the editor to pick up
  const contentJson = JSON.stringify(note ? (note.content || '') : '')

  let content = ''

  // Editor toolbar
  content += `<div class="editor-toolbar">
    <a class="back-link" href="${pathPrefix}/notebooks/${encodeURIComponent(nbSlug)}">&larr; ${esc(notebook.name || nbSlug)}</a>
    <input type="text" id="editor-title" placeholder="Note title" value="${esc(title)}">
    <input type="text" id="editor-tags" placeholder="Tags (comma-separated)" value="${esc(tags)}">
    <div class="editor-actions">
      <button class="btn btn-primary" id="btn-save">Save</button>
      <a class="btn" href="${pathPrefix}/notebooks/${encodeURIComponent(nbSlug)}${noteId ? '/notes/' + encodeURIComponent(note.slug || noteId) : ''}">Cancel</a>
    </div>
  </div>`

  // Editor container — JS fills this
  content += '<div id="editor-container"></div>'

  // Upload progress overlay (hidden)
  content += `<div id="upload-overlay" style="display:none">
    <div class="upload-box">
      <div id="upload-label">Uploading...</div>
      <div class="upload-track"><div id="upload-bar" class="upload-bar"></div></div>
      <div id="upload-pct">0%</div>
    </div>
  </div>`

  const css = `
    .editor-toolbar { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-bottom:16px; }
    .editor-toolbar input[type="text"] { flex:1; min-width:200px; }
    .editor-actions { display:flex; gap:8px; margin-left:auto; }
    #editor-container { min-height:60vh; }
    #upload-overlay {
      position:fixed; top:0; left:0; right:0; bottom:0;
      background:rgba(0,0,0,0.5); z-index:10000;
      display:flex; align-items:center; justify-content:center;
    }
    .upload-box { background:var(--bg-secondary); padding:24px; border-radius:8px; min-width:300px; text-align:center; }
    .upload-track { height:8px; background:var(--bg-card); border-radius:4px; margin:12px 0; overflow:hidden; }
    .upload-bar { height:100%; background:var(--accent); border-radius:4px; width:0%; transition:width 0.2s; }
    /* TOUI editor theme overrides */
    .toastui-editor-defaultUI { background:var(--bg) !important; }
    .toastui-editor-defaultUI .toastui-editor-md-container { background:var(--bg) !important; }
    .ProseMirror { color:var(--text) !important; min-height:50vh; }
  `

  // Vendor scripts needed for the editor
  const vendorHead = `
    <link rel="stylesheet" href="${pathPrefix}/web/vendor/toastui-editor.min.css">
    <link rel="stylesheet" href="${pathPrefix}/web/vendor/jsuites.min.css">
    <link rel="stylesheet" href="${pathPrefix}/web/vendor/jspreadsheet.min.css">
    <script src="${pathPrefix}/web/vendor/toastui-editor-all.min.js"></script>
    <script src="${pathPrefix}/web/vendor/jsuites.min.js"></script>
    <script src="${pathPrefix}/web/vendor/jspreadsheet.min.js"></script>
    <script src="${pathPrefix}/web/vendor/fullcalendar.min.js"></script>
  `

  const scripts = `
  (function () {
    var basePath = ${JSON.stringify(pathPrefix)};
    var nbSlug = ${JSON.stringify(nbSlug)};
    var noteId = ${JSON.stringify(noteId)};
    var noteType = ${JSON.stringify(noteType)};
    var initialContent = ${contentJson};
    var container = document.getElementById('editor-container');

    var tuiEditor = null;
    var spreadsheetInstance = null;
    var calendarInstance = null;

    // ========== UPLOAD HELPERS ==========

    function showUploadProgress (label) {
      document.getElementById('upload-label').textContent = label || 'Uploading...';
      document.getElementById('upload-bar').style.width = '0%';
      document.getElementById('upload-pct').textContent = '0%';
      document.getElementById('upload-overlay').style.display = 'flex';
    }

    function updateUploadProgress (pct) {
      document.getElementById('upload-bar').style.width = pct + '%';
      document.getElementById('upload-pct').textContent = Math.round(pct) + '%';
    }

    function hideUploadProgress () {
      document.getElementById('upload-overlay').style.display = 'none';
    }

    async function uploadMedia (file, onProgress) {
      var headers = {};
      if (window.HnIdentity && window.HnIdentity.getPublicKey()) {
        try {
          var ch = await window.HnIdentity.getFreshChallenge();
          var sig = await window.HnIdentity.signChallenge(ch.challenge);
          headers['X-Wirecore-PublicKey'] = window.HnIdentity.getPublicKey();
          headers['X-Wirecore-Signature'] = sig;
          headers['X-Wirecore-Challenge'] = ch.challengeId;
        } catch (e) {}
      }
      return new Promise(function (resolve, reject) {
        var xhr = new XMLHttpRequest();
        xhr.open('POST', basePath + '/api/notebooks/' + encodeURIComponent(nbSlug) + '/media');
        xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
        for (var h in headers) xhr.setRequestHeader(h, headers[h]);
        xhr.upload.onprogress = function (e) { if (e.lengthComputable && onProgress) onProgress(e.loaded / e.total * 100); };
        xhr.onload = function () {
          if (xhr.status >= 200 && xhr.status < 300) {
            try { resolve(JSON.parse(xhr.responseText)); } catch (e) { reject(new Error('Invalid response')); }
          } else { reject(new Error('Upload failed: ' + xhr.status)); }
        };
        xhr.onerror = function () { reject(new Error('Network error')); };
        xhr.send(file);
      });
    }

    function insertRawMarkdown (text) {
      if (!tuiEditor) return;
      tuiEditor.insertText(text);
    }

    // ========== MARKDOWN EDITOR ==========

    if (noteType === 'markdown' || noteType === 'md' || !noteType) {
      // Video upload button
      var videoBtnEl = document.createElement('button');
      videoBtnEl.className = 'toastui-editor-toolbar-icons';
      videoBtnEl.style.cssText = 'background:none;font-size:14px;cursor:pointer;margin:0;padding:4px 8px;';
      videoBtnEl.textContent = '🎬';
      videoBtnEl.title = 'Insert video';
      var videoInput = document.createElement('input');
      videoInput.type = 'file'; videoInput.accept = 'video/*'; videoInput.style.display = 'none';
      document.body.appendChild(videoInput);
      videoBtnEl.addEventListener('click', function () { videoInput.click(); });
      videoInput.addEventListener('change', async function () {
        if (!videoInput.files[0]) return;
        showUploadProgress('Uploading video...');
        try {
          var data = await uploadMedia(videoInput.files[0], updateUploadProgress);
          hideUploadProgress();
          insertRawMarkdown('![video](' + basePath + data.url + ')');
        } catch (e) { hideUploadProgress(); alert('Upload failed: ' + e.message); }
        videoInput.value = '';
      });

      // Gallery upload button
      var galleryBtnEl = document.createElement('button');
      galleryBtnEl.className = 'toastui-editor-toolbar-icons';
      galleryBtnEl.style.cssText = 'background:none;font-size:14px;cursor:pointer;margin:0;padding:4px 8px;';
      galleryBtnEl.textContent = '🖼';
      galleryBtnEl.title = 'Insert gallery';
      var galleryInput = document.createElement('input');
      galleryInput.type = 'file'; galleryInput.accept = 'image/*'; galleryInput.multiple = true; galleryInput.style.display = 'none';
      document.body.appendChild(galleryInput);
      galleryBtnEl.addEventListener('click', function () { galleryInput.click(); });
      galleryInput.addEventListener('change', async function () {
        var files = Array.from(galleryInput.files);
        if (files.length === 0) return;
        showUploadProgress('Uploading gallery (' + files.length + ' images)...');
        var lines = [];
        for (var i = 0; i < files.length; i++) {
          updateUploadProgress((i / files.length) * 100);
          try {
            var data = await uploadMedia(files[i], function () {});
            lines.push('![' + (files[i].name || 'image') + '](' + basePath + data.url + ')');
          } catch (e) { console.error('Gallery upload failed:', e); }
        }
        hideUploadProgress();
        if (lines.length) insertRawMarkdown('\\n\`\`\`gallery\\n' + lines.join('\\n') + '\\n\`\`\`\\n');
        galleryInput.value = '';
      });

      tuiEditor = new toastui.Editor({
        el: container,
        initialEditType: 'markdown',
        previewStyle: 'vertical',
        height: 'calc(100vh - 200px)',
        initialValue: initialContent,
        usageStatistics: false,
        toolbarItems: [
          ['heading', 'bold', 'italic', 'strike'],
          ['hr', 'quote'],
          ['ul', 'ol', 'task', 'indent', 'outdent'],
          ['table', 'image', 'link'],
          ['code', 'codeblock'],
          [{ name: 'video', tooltip: 'Insert video', el: videoBtnEl },
           { name: 'gallery', tooltip: 'Insert gallery', el: galleryBtnEl }],
          ['scrollSync']
        ],
        hooks: {
          addImageBlobHook: function (blob, callback) {
            var ct = blob.type || 'application/octet-stream';
            var label = ct.startsWith('video/') ? 'Uploading video...' : 'Uploading image...';
            showUploadProgress(label);
            uploadMedia(blob, updateUploadProgress)
              .then(function (data) {
                hideUploadProgress();
                var mediaUrl = basePath + data.url;
                if (ct.startsWith('video/')) { insertRawMarkdown('![video](' + mediaUrl + ')'); callback('', ''); }
                else { callback(mediaUrl, blob.name || 'image'); }
              })
              .catch(function (err) { hideUploadProgress(); alert('Upload failed: ' + err.message); callback('', ''); });
            return false;
          }
        }
      });
    }

    // ========== SPREADSHEET EDITOR ==========

    else if (noteType === 'spreadsheet') {
      var sheetData = [['', '', '', ''], ['', '', '', ''], ['', '', '', '']];
      var sheetColumns;
      if (initialContent) {
        try {
          var parsed = JSON.parse(initialContent);
          sheetData = parsed.data || sheetData;
          sheetColumns = parsed.columns;
        } catch (e) {}
      }
      spreadsheetInstance = jspreadsheet(container, {
        data: sheetData,
        columns: sheetColumns,
        minDimensions: [4, 5],
        tableOverflow: true,
        tableWidth: '100%'
      });
    }

    // ========== CALENDAR EDITOR ==========

    else if (noteType === 'calendar') {
      var calData = { events: [], settings: {} };
      if (initialContent) { try { calData = JSON.parse(initialContent); } catch (e) {} }
      var calSettings = calData.settings || {};

      calendarInstance = new FullCalendar.Calendar(container, {
        initialView: calSettings.defaultView || 'dayGridMonth',
        firstDay: calSettings.firstDay != null ? calSettings.firstDay : 1,
        headerToolbar: { left: 'prev,next today', center: 'title', right: 'dayGridMonth,timeGridWeek,timeGridDay' },
        events: (calData.events || []).map(function (ev) {
          return { id: ev.id, title: ev.title, start: ev.start, end: ev.end, allDay: ev.allDay, color: ev.color, extendedProps: { description: ev.description } };
        }),
        editable: true,
        selectable: true,
        height: 'auto',
        dateClick: function (info) { showEventModal({ start: info.dateStr, allDay: info.allDay }); },
        eventClick: function (info) {
          var ev = info.event;
          showEventModal({ id: ev.id, title: ev.title, start: ev.startStr, end: ev.endStr, allDay: ev.allDay, color: ev.backgroundColor, description: (ev.extendedProps && ev.extendedProps.description) || '' });
        }
      });
      calendarInstance.render();

      function showEventModal (evt) {
        var isEdit = !!evt.id;
        var modal = document.createElement('div');
        modal.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:10000;display:flex;align-items:center;justify-content:center';
        modal.innerHTML = '<div style="background:var(--bg-secondary);padding:24px;border-radius:8px;min-width:350px;max-width:500px">'
          + '<h3>' + (isEdit ? 'Edit Event' : 'New Event') + '</h3>'
          + '<div style="margin:12px 0"><label>Title</label><input type="text" id="evt-title" value="' + esc(evt.title || '') + '" style="width:100%"></div>'
          + '<div style="margin:12px 0"><label>Start</label><input type="text" id="evt-start" value="' + esc(evt.start || '') + '" style="width:100%"></div>'
          + '<div style="margin:12px 0"><label>End</label><input type="text" id="evt-end" value="' + esc(evt.end || '') + '" style="width:100%"></div>'
          + '<div style="margin:12px 0"><label>Color</label><input type="color" id="evt-color" value="' + (evt.color || '#569cd6') + '"></div>'
          + '<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">'
          + (isEdit ? '<button id="evt-delete" class="btn btn-danger" style="margin-right:auto">Delete</button>' : '')
          + '<button id="evt-cancel" class="btn">Cancel</button>'
          + '<button id="evt-save" class="btn btn-primary">Save</button></div></div>';
        document.body.appendChild(modal);
        modal.querySelector('#evt-cancel').addEventListener('click', function () { modal.remove(); });
        if (isEdit) modal.querySelector('#evt-delete').addEventListener('click', function () {
          var existing = calendarInstance.getEventById(evt.id);
          if (existing) existing.remove();
          modal.remove();
        });
        modal.querySelector('#evt-save').addEventListener('click', function () {
          var newEvt = {
            id: evt.id || 'evt_' + Date.now(),
            title: modal.querySelector('#evt-title').value || 'Untitled',
            start: modal.querySelector('#evt-start').value,
            end: modal.querySelector('#evt-end').value || null,
            color: modal.querySelector('#evt-color').value
          };
          if (isEdit) { var existing = calendarInstance.getEventById(evt.id); if (existing) existing.remove(); }
          calendarInstance.addEvent(newEvt);
          modal.remove();
        });
      }
    }

    // ========== SAVE ==========

    document.getElementById('btn-save').addEventListener('click', async function () {
      var titleInput = document.getElementById('editor-title');
      var tagsInput = document.getElementById('editor-tags');
      var noteTitle = titleInput.value.trim();
      var noteTags = tagsInput.value.split(',').map(function (t) { return t.trim(); }).filter(function (t) { return t; });

      if (!noteTitle) { alert('Title is required'); titleInput.focus(); return; }

      var content = '';
      var type = noteType;

      if (spreadsheetInstance) {
        type = 'spreadsheet';
        var sheetData = spreadsheetInstance.getData ? spreadsheetInstance.getData() : [];
        var sheetHeaders = spreadsheetInstance.getHeaders ? spreadsheetInstance.getHeaders(true) : [];
        content = JSON.stringify({ data: sheetData, columns: sheetHeaders.length ? sheetHeaders.map(function (h) { return { title: h }; }) : undefined });
      } else if (calendarInstance) {
        type = 'calendar';
        var events = calendarInstance.getEvents().map(function (ev) {
          return { id: ev.id, title: ev.title, start: ev.startStr, end: ev.endStr || null, allDay: ev.allDay, color: ev.backgroundColor || null, description: (ev.extendedProps && ev.extendedProps.description) || '' };
        });
        content = JSON.stringify({ events: events, settings: { defaultView: calendarInstance.view.type, firstDay: 1 } });
      } else if (tuiEditor) {
        content = tuiEditor.getMarkdown();
      }

      var btn = document.getElementById('btn-save');
      btn.disabled = true; btn.textContent = 'Saving...';

      try {
        if (noteId) {
          await window.fetch(basePath + '/api/notebooks/' + encodeURIComponent(nbSlug) + '/notes/' + encodeURIComponent(noteId), {
            method: 'PATCH',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ title: noteTitle, content: content, tags: noteTags })
          });
          window.location.href = basePath + '/notebooks/' + encodeURIComponent(nbSlug) + '/notes/' + encodeURIComponent(noteId);
        } else {
          var res = await window.fetch(basePath + '/api/notebooks/' + encodeURIComponent(nbSlug) + '/notes', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ title: noteTitle, content: content, tags: noteTags, type: type })
          });
          var data = await res.json();
          var note = data.note || data;
          window.location.href = basePath + '/notebooks/' + encodeURIComponent(nbSlug) + '/notes/' + encodeURIComponent(note.slug || note.id);
        }
      } catch (err) {
        alert('Error saving: ' + (err.message || 'Unknown error'));
        btn.disabled = false; btn.textContent = 'Save';
      }
    });
  })();
  `

  const pageTitle = isNew ? 'New Note' : 'Edit: ' + title
  return renderPage(pageTitle, content, { pathPrefix, activePage: '', css, scripts, head: vendorHead, maxWidth: 1200 })
}