novela/containers/novela/static/editor.js
2026-03-31 20:03:18 +02:00

427 lines
14 KiB
JavaScript

require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' } });
const { filename } = EDITOR;
let editor = null;
let chapters = [];
let currentIndex = -1;
let dirty = new Set(); // indices with unsaved changes
let pendingContent = new Map(); // index -> modified content not yet saved
let loadingChapter = false; // suppress dirty events during setValue
let saving = false;
// ── Init Monaco ───────────────────────────────────────────────────────────────
require(['vs/editor/editor.main'], function () {
editor = monaco.editor.create(document.getElementById('editor-pane'), {
language: 'xml',
theme: 'vs-dark',
wordWrap: 'on',
minimap: { enabled: true },
fontSize: 13,
fontFamily: "'DM Mono', monospace",
lineNumbers: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
});
editor.onDidChangeModelContent(() => {
if (loadingChapter) return;
if (currentIndex >= 0) {
dirty.add(currentIndex);
renderChapterList();
setStatus('dirty', 'Unsaved changes');
document.getElementById('btn-save').disabled = false;
updateSaveAll();
}
});
// Ctrl+S / Cmd+S
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, saveChapter);
loadChapterList();
});
// ── Chapter list ──────────────────────────────────────────────────────────────
async function loadChapterList(targetIndex = 0) {
const resp = await fetch(`/library/chapters/${encodeURIComponent(filename)}`);
if (!resp.ok) {
setStatus('error', 'Failed to load chapters');
return;
}
chapters = await resp.json();
if (!Array.isArray(chapters)) chapters = [];
if (chapters.length === 0) {
currentIndex = -1;
dirty.clear();
pendingContent.clear();
renderChapterList();
document.getElementById('header-chapter').textContent = 'No chapters';
document.getElementById('btn-save').disabled = true;
document.getElementById('btn-break').disabled = true;
document.getElementById('btn-del-page').disabled = true;
if (editor) { loadingChapter = true; editor.setValue(''); loadingChapter = false; }
updateSaveAll();
return;
}
const next = Math.min(Math.max(targetIndex, 0), chapters.length - 1);
renderChapterList();
await loadChapter(next);
}
function renderChapterList() {
const el = document.getElementById('chapter-list');
el.innerHTML = '';
chapters.forEach((ch, i) => {
const item = document.createElement('div');
item.className = 'chapter-item' + (i === currentIndex ? ' active' : '');
item.innerHTML =
(dirty.has(i) ? '<span class="dirty-dot"></span>' : '') +
`<span class="chapter-item-title">${esc(ch.title)}</span>`;
item.onclick = () => switchChapter(i);
el.appendChild(item);
});
}
// ── Load / switch ─────────────────────────────────────────────────────────────
async function switchChapter(index) {
if (index === currentIndex) return;
// Preserve current editor content in pending cache before switching (never lose changes)
if (dirty.has(currentIndex) && editor) {
pendingContent.set(currentIndex, editor.getValue());
}
loadChapter(index);
}
async function loadChapter(index) {
setStatus('', '');
document.getElementById('btn-save').disabled = true;
document.getElementById('btn-break').disabled = true;
document.getElementById('btn-del-page').disabled = true;
document.getElementById('header-chapter').textContent = 'Loading…';
let content, title;
if (pendingContent.has(index)) {
content = pendingContent.get(index);
title = chapters[index]?.title ?? '';
} else {
const resp = await fetch(`/api/edit/chapter/${index}/${encodeURIComponent(filename)}`);
if (!resp.ok) { setStatus('error', 'Load failed'); return; }
const data = await resp.json();
content = data.content;
title = data.title;
}
currentIndex = index;
loadingChapter = true;
editor.setValue(content);
editor.setScrollTop(0);
loadingChapter = false;
// Restore dirty state based on whether we loaded from pending cache
if (dirty.has(index)) {
document.getElementById('btn-save').disabled = false;
setStatus('dirty', 'Unsaved changes');
} else {
document.getElementById('btn-save').disabled = true;
setStatus('', '');
}
renderChapterList();
document.getElementById('header-chapter').textContent = title;
document.getElementById('btn-break').disabled = false;
document.getElementById('btn-del-page').disabled = chapters.length <= 1;
updateSaveAll();
}
// ── Save (current chapter) ────────────────────────────────────────────────────
async function saveChapter() {
if (currentIndex < 0 || saving) return;
saving = true;
document.getElementById('btn-save').disabled = true;
setStatus('saving', 'Saving…');
try {
const resp = await fetch(
`/api/edit/chapter/${currentIndex}/${encodeURIComponent(filename)}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: editor.getValue() }),
}
);
const data = await resp.json();
if (data.ok) {
dirty.delete(currentIndex);
pendingContent.delete(currentIndex);
renderChapterList();
setStatus('saved', 'Saved');
setTimeout(() => setStatus('', ''), 2000);
updateSaveAll();
} else {
setStatus('error', data.error || 'Save failed');
document.getElementById('btn-save').disabled = false;
}
} catch {
setStatus('error', 'Save failed');
document.getElementById('btn-save').disabled = false;
} finally {
saving = false;
}
}
// ── Save all pending ──────────────────────────────────────────────────────────
async function saveAllChapters() {
if (saving) return;
saving = true;
const btn = document.getElementById('btn-save-all');
if (btn) btn.disabled = true;
setStatus('saving', 'Saving all…');
// Flush current editor content into pendingContent first
if (currentIndex >= 0 && dirty.has(currentIndex)) {
pendingContent.set(currentIndex, editor.getValue());
}
const indices = [...dirty];
for (const i of indices) {
const content = pendingContent.has(i)
? pendingContent.get(i)
: (i === currentIndex ? editor.getValue() : null);
if (!content) continue;
try {
const resp = await fetch(
`/api/edit/chapter/${i}/${encodeURIComponent(filename)}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
}
);
const data = await resp.json();
if (data.ok) {
dirty.delete(i);
pendingContent.delete(i);
}
} catch {
setStatus('error', `Save failed on chapter ${i + 1}`);
saving = false;
updateSaveAll();
return;
}
}
// Reload current chapter display to reflect saved state
if (currentIndex >= 0) {
loadingChapter = true;
editor.setValue(editor.getValue()); // no-op, just clears dirty for display
loadingChapter = false;
document.getElementById('btn-save').disabled = true;
}
renderChapterList();
setStatus('saved', 'All saved');
setTimeout(() => setStatus('', ''), 2000);
saving = false;
updateSaveAll();
}
function updateSaveAll() {
const btn = document.getElementById('btn-save-all');
if (!btn) return;
const count = dirty.size;
if (count > 1) {
btn.style.display = 'flex';
btn.textContent = `Save all (${count})`;
} else {
btn.style.display = 'none';
}
}
// ── Insert break ──────────────────────────────────────────────────────────────
function insertBreak() {
if (!editor || currentIndex < 0) return;
const pos = editor.getPosition();
editor.executeEdits('insert-break', [{
range: new monaco.Range(pos.lineNumber, pos.column, pos.lineNumber, pos.column),
text: '\n<center><img src="../Images/break.png" style="height:15px;"/></center>\n',
forceMoveMarkers: true,
}]);
editor.focus();
}
// ── Add / delete chapter ─────────────────────────────────────────────────────
async function addChapter() {
if (saving) return;
if (dirty.size > 0) {
alert('Save pending changes before adding a page.');
return;
}
const title = prompt('Title for new page:', `New chapter ${Math.max(chapters.length + 1, 1)}`);
if (title === null) return;
const resp = await fetch(`/api/edit/chapter/add/${encodeURIComponent(filename)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, after_index: currentIndex }),
});
const data = await resp.json();
if (!resp.ok || !data.ok) {
setStatus('error', data.error || 'Add page failed');
return;
}
dirty.clear();
pendingContent.clear();
await loadChapterList(data.index ?? Math.max(currentIndex + 1, 0));
setStatus('saved', 'Page added');
setTimeout(() => setStatus('', ''), 1500);
}
async function deleteChapter() {
if (saving || currentIndex < 0) return;
if (chapters.length <= 1) {
alert('Cannot delete the last page.');
return;
}
if (dirty.size > 0) {
alert('Save pending changes before deleting a page.');
return;
}
const chTitle = chapters[currentIndex]?.title || `chapter ${currentIndex + 1}`;
if (!confirm(`Delete page "${chTitle}"?`)) return;
const resp = await fetch(`/api/edit/chapter/${currentIndex}/${encodeURIComponent(filename)}`, {
method: 'DELETE',
});
const data = await resp.json();
if (!resp.ok || !data.ok) {
setStatus('error', data.error || 'Delete page failed');
return;
}
dirty.clear();
pendingContent.clear();
await loadChapterList(data.index ?? Math.max(currentIndex - 1, 0));
setStatus('saved', 'Page deleted');
setTimeout(() => setStatus('', ''), 1500);
}
// ── Find & Replace all chapters ───────────────────────────────────────────────
function openReplaceModal() {
document.getElementById('replace-modal').classList.add('open');
document.getElementById('rp-search').focus();
document.getElementById('rp-progress').textContent = '';
document.getElementById('rp-progress').className = 'modal-progress';
document.getElementById('rp-run').disabled = false;
}
function closeReplaceModal() {
document.getElementById('replace-modal').classList.remove('open');
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeReplaceModal();
});
async function replaceInAllChapters() {
const searchVal = document.getElementById('rp-search').value;
if (!searchVal) return;
const replaceVal = document.getElementById('rp-replace').value;
const useRegex = document.getElementById('rp-regex').checked;
const caseSens = document.getElementById('rp-case').checked;
const runBtn = document.getElementById('rp-run');
const prog = document.getElementById('rp-progress');
runBtn.disabled = true;
let pattern;
try {
pattern = useRegex
? new RegExp(searchVal, caseSens ? 'g' : 'gi')
: new RegExp(searchVal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), caseSens ? 'g' : 'gi');
} catch (e) {
prog.className = 'modal-progress error';
prog.textContent = 'Invalid regex: ' + e.message;
runBtn.disabled = false;
return;
}
let totalOccurrences = 0;
let chaptersChanged = 0;
// Flush current editor content into pending before we start
if (currentIndex >= 0) {
pendingContent.set(currentIndex, editor.getValue());
}
for (let i = 0; i < chapters.length; i++) {
prog.className = 'modal-progress';
prog.textContent = `Checking chapter ${i + 1} / ${chapters.length}`;
let original;
if (pendingContent.has(i)) {
original = pendingContent.get(i);
} else {
try {
const resp = await fetch(`/api/edit/chapter/${i}/${encodeURIComponent(filename)}`);
if (!resp.ok) continue;
const data = await resp.json();
original = data.content;
} catch {
continue;
}
}
// Count occurrences
let count = 0;
const updated = original.replace(pattern, m => { count++; return replaceVal; });
if (count === 0) continue;
totalOccurrences += count;
chaptersChanged++;
pendingContent.set(i, updated);
dirty.add(i);
}
// Reload current chapter from pending cache if it was changed
if (dirty.has(currentIndex) && pendingContent.has(currentIndex)) {
loadingChapter = true;
editor.setValue(pendingContent.get(currentIndex));
loadingChapter = false;
document.getElementById('btn-save').disabled = false;
setStatus('dirty', 'Unsaved changes');
}
renderChapterList();
updateSaveAll();
prog.className = totalOccurrences > 0 ? 'modal-progress ok' : 'modal-progress';
prog.textContent = totalOccurrences > 0
? `${totalOccurrences} replacement${totalOccurrences !== 1 ? 's' : ''} in ${chaptersChanged} chapter${chaptersChanged !== 1 ? 's' : ''} — not saved yet.`
: 'No matches found.';
runBtn.disabled = false;
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function setStatus(cls, text) {
const el = document.getElementById('save-status');
el.className = 'save-status' + (cls ? ' ' + cls : '');
el.textContent = text;
}