novela/containers/novela/static/editor.js
Ivo Oskamp 3ce9df9bae Editor: Find & Replace scope option
Add a "Current chapter only" checkbox to the Find & Replace modal. When
checked, search/replace runs against the open chapter instead of every chapter
in the book. Default unchecked, preserving the existing all-chapters behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:02:03 +02:00

700 lines
25 KiB
JavaScript

require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' } });
const { filename, is_db } = EDITOR;
// ── State ─────────────────────────────────────────────────────────────────────
let editor = null;
let chapters = []; // [{title, href, _id, _new, _serverIndex}, ...]
let nextLocalId = 0;
let pendingDeletes = []; // [{_serverIndex, title}, ...] to be deleted on save
let currentIndex = -1; // index into chapters[]
let dirty = new Set(); // chapter _ids with unsaved content/title changes
let pendingContent = new Map(); // _id -> content string
let pendingTitles = new Map(); // _id -> title string (DB only)
let structureDirty = false; // pending adds or deletes not yet on server
let loadingChapter = false;
let saving = false;
function currentCh() { return currentIndex >= 0 ? chapters[currentIndex] : null; }
// ── Init Monaco ───────────────────────────────────────────────────────────────
require(['vs/editor/editor.main'], function () {
document.getElementById('header-chapter').style.display = 'none';
document.getElementById('chapter-title-input').style.display = '';
editor = monaco.editor.create(document.getElementById('editor-pane'), {
language: is_db ? 'html' : '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;
const ch = currentCh();
if (ch) {
dirty.add(ch._id);
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);
document.getElementById('chapter-title-input').addEventListener('input', () => {
const ch = currentCh();
if (ch) {
pendingTitles.set(ch._id, document.getElementById('chapter-title-input').value);
dirty.add(ch._id);
renderChapterList();
setStatus('dirty', 'Unsaved changes');
document.getElementById('btn-save').disabled = false;
updateSaveAll();
}
});
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;
}
const raw = await resp.json();
chapters = Array.isArray(raw)
? raw.map((ch, i) => ({ ...ch, _id: nextLocalId++, _new: false, _serverIndex: i }))
: [];
pendingDeletes = [];
dirty.clear();
pendingContent.clear();
pendingTitles.clear();
structureDirty = false;
if (chapters.length === 0) {
currentIndex = -1;
renderChapterList();
document.getElementById('header-chapter').textContent = 'No chapters';
document.getElementById('btn-save').disabled = true;
document.getElementById('btn-break').disabled = true;
document.getElementById('btn-subheading').disabled = true;
document.getElementById('btn-chat').disabled = true;
document.getElementById('btn-indent').disabled = true;
document.getElementById('btn-comment').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' : '');
const dot = dirty.has(ch._id) ? '<span class="dirty-dot"></span>' : '';
item.innerHTML = dot + `<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;
// Flush current content/title to pending cache before switching
const ch = currentCh();
if (ch) {
if (dirty.has(ch._id) && editor) pendingContent.set(ch._id, editor.getValue());
const inp = document.getElementById('chapter-title-input');
if (inp) pendingTitles.set(ch._id, inp.value);
}
await loadChapter(index);
}
async function loadChapter(index) {
setStatus('', '');
document.getElementById('btn-save').disabled = true;
document.getElementById('btn-break').disabled = true;
document.getElementById('btn-subheading').disabled = true;
document.getElementById('btn-chat').disabled = true;
document.getElementById('btn-del-page').disabled = true;
if (!is_db) document.getElementById('header-chapter').textContent = 'Loading…';
const ch = chapters[index];
if (!ch) return;
let content, title;
if (pendingContent.has(ch._id)) {
content = pendingContent.get(ch._id);
title = pendingTitles.has(ch._id) ? pendingTitles.get(ch._id) : ch.title;
} else if (ch._new) {
// New chapter not yet on server — starts empty
content = '';
title = pendingTitles.has(ch._id) ? pendingTitles.get(ch._id) : ch.title;
} else {
const resp = await fetch(`/api/edit/chapter/${ch._serverIndex}/${encodeURIComponent(filename)}`);
if (!resp.ok) { setStatus('error', 'Load failed'); return; }
const data = await resp.json();
content = data.content;
title = pendingTitles.has(ch._id) ? pendingTitles.get(ch._id) : data.title;
}
currentIndex = index;
loadingChapter = true;
editor.setValue(content);
editor.setScrollTop(0);
loadingChapter = false;
editor.focus();
const hasChanges = dirty.has(ch._id) || structureDirty;
document.getElementById('btn-save').disabled = !hasChanges;
if (hasChanges) setStatus('dirty', 'Unsaved changes');
renderChapterList();
document.getElementById('chapter-title-input').value = title;
document.getElementById('btn-break').disabled = false;
document.getElementById('btn-subheading').disabled = false;
document.getElementById('btn-chat').disabled = false;
document.getElementById('btn-indent').disabled = false;
document.getElementById('btn-comment').disabled = false;
document.getElementById('btn-del-page').disabled = chapters.length <= 1;
updateSaveAll();
}
// ── Save (current chapter) ────────────────────────────────────────────────────
async function saveChapter() {
if (saving) return;
saving = true;
document.getElementById('btn-save').disabled = true;
// Apply structural changes (add/delete) before saving content
if (structureDirty) {
setStatus('saving', 'Applying changes…');
try {
await applyStructuralChanges();
} catch (e) {
setStatus('error', e.message || 'Failed to apply changes');
document.getElementById('btn-save').disabled = false;
saving = false;
return;
}
renderChapterList();
}
const ch = currentCh();
if (!ch || !dirty.has(ch._id)) {
// Structural changes saved, no content changes for this chapter
setStatus('saved', 'Saved');
setTimeout(() => setStatus('', ''), 2000);
saving = false;
updateSaveAll();
return;
}
setStatus('saving', 'Saving…');
try {
const saveBody = { content: editor.getValue() };
const inp = document.getElementById('chapter-title-input');
saveBody.title = inp ? inp.value.trim() : (pendingTitles.get(ch._id) || '');
const resp = await fetch(
`/api/edit/chapter/${ch._serverIndex}/${encodeURIComponent(filename)}`,
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(saveBody) }
);
const data = await resp.json();
if (data.ok) {
dirty.delete(ch._id);
pendingContent.delete(ch._id);
ch.title = pendingTitles.get(ch._id) || ch.title;
pendingTitles.delete(ch._id);
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 and title into pending caches first
const ch = currentCh();
if (ch && dirty.has(ch._id)) {
pendingContent.set(ch._id, editor.getValue());
const inp = document.getElementById('chapter-title-input');
if (inp) pendingTitles.set(ch._id, inp.value);
}
// Apply structural changes first
if (structureDirty) {
setStatus('saving', 'Applying changes…');
try {
await applyStructuralChanges();
} catch (e) {
setStatus('error', e.message || 'Failed to apply changes');
saving = false;
updateSaveAll();
return;
}
renderChapterList();
}
const ids = [...dirty];
for (const id of ids) {
const chapter = chapters.find(c => c._id === id);
if (!chapter) { dirty.delete(id); continue; }
const content = pendingContent.has(id)
? pendingContent.get(id)
: (chapter._id === currentCh()?._id ? editor.getValue() : null);
const hasTitleChange = pendingTitles.has(id);
if (!content && !hasTitleChange) continue;
try {
const saveBody = { content: content || '' };
saveBody.title = pendingTitles.has(id) ? pendingTitles.get(id) : (chapter.title || '');
const resp = await fetch(
`/api/edit/chapter/${chapter._serverIndex}/${encodeURIComponent(filename)}`,
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(saveBody) }
);
const data = await resp.json();
if (data.ok) {
dirty.delete(id);
pendingContent.delete(id);
chapter.title = pendingTitles.get(id) || chapter.title;
pendingTitles.delete(id);
}
} catch {
setStatus('error', 'Save failed');
saving = false;
updateSaveAll();
return;
}
}
loadingChapter = true;
editor.setValue(editor.getValue()); // refresh 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})`;
btn.disabled = false;
} else {
btn.style.display = 'none';
}
}
// ── Apply structural changes (adds/deletes) ───────────────────────────────────
async function applyStructuralChanges() {
// Step 1: apply deletes in descending server-index order so earlier indices stay valid
const sorted = [...pendingDeletes].sort((a, b) => b._serverIndex - a._serverIndex);
for (const del of sorted) {
const resp = await fetch(
`/api/edit/chapter/${del._serverIndex}/${encodeURIComponent(filename)}`,
{ method: 'DELETE' }
);
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || 'Delete failed');
}
// Shift server indices for remaining chapters
chapters.forEach(c => {
if (c._serverIndex !== null && c._serverIndex > del._serverIndex) c._serverIndex--;
});
}
pendingDeletes = [];
// Step 2: apply adds in order of appearance in chapters[]
for (const ch of chapters.filter(c => c._new)) {
const localIdx = chapters.indexOf(ch);
// Find nearest preceding chapter that already has a server index
let afterServerIndex = -1;
for (let j = localIdx - 1; j >= 0; j--) {
if (chapters[j]._serverIndex !== null) {
afterServerIndex = chapters[j]._serverIndex;
break;
}
}
const title = pendingTitles.has(ch._id) ? pendingTitles.get(ch._id) : ch.title;
const resp = await fetch(
`/api/edit/chapter/add/${encodeURIComponent(filename)}`,
{ method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, after_index: afterServerIndex }) }
);
const data = await resp.json();
if (!resp.ok || !data.ok) throw new Error(data.error || 'Add chapter failed');
const addedIdx = data.index;
// Shift server indices for chapters inserted after this position
chapters.forEach(c => {
if (c._id !== ch._id && c._serverIndex !== null && c._serverIndex >= addedIdx) c._serverIndex++;
});
ch._serverIndex = addedIdx;
ch._new = false;
}
structureDirty = false;
}
// ── Insert break ──────────────────────────────────────────────────────────────
function insertBreak() {
if (!editor || currentIndex < 0) return;
const breakSrc = is_db ? '/static/break.png' : '../Images/break.png';
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="${breakSrc}" style="height:15px;"/></center>\n`,
forceMoveMarkers: true,
}]);
editor.focus();
}
// ── Wrap selection in tag ─────────────────────────────────────────────────────
function wrapTag(tag, attrs) {
if (!editor || currentIndex < 0) return;
const sel = editor.getSelection();
const open = attrs ? `<${tag} ${attrs}>` : `<${tag}>`;
const close = `</${tag}>`;
const selectedText = editor.getModel().getValueInRange(sel);
const isEmpty = sel.startLineNumber === sel.endLineNumber && sel.startColumn === sel.endColumn;
editor.executeEdits('wrap-tag', [{
range: sel,
text: open + selectedText + close,
forceMoveMarkers: true,
}]);
if (isEmpty) {
const pos = editor.getPosition();
editor.setPosition({ lineNumber: pos.lineNumber, column: pos.column - close.length });
}
editor.focus();
}
// Wraps selected text in a span (inline). If the selection contains block
// elements (<p>, <div>, <h*>) the span is replaced by a <div> so the result
// stays valid HTML.
function wrapSpan(cls) {
if (!editor || currentIndex < 0) return;
const sel = editor.getSelection();
const selectedText = editor.getModel().getValueInRange(sel);
const hasBlock = /<(p|div|h[1-6]|blockquote|ul|ol|li)[\s>]/i.test(selectedText);
const tag = hasBlock ? 'div' : 'span';
wrapTag(tag, `class="${cls}"`);
}
function insertIndent() {
if (!editor || currentIndex < 0) return;
const sel = editor.getSelection();
const selectedText = editor.getModel().getValueInRange(sel);
const hasBlock = /<(p|div|h[1-6]|blockquote|ul|ol|li)[\s>]/i.test(selectedText);
// If selection contains block elements wrap in a div, else in a p
const tag = hasBlock ? 'div' : 'p';
wrapTag(tag, 'style="padding-left: 40px;"');
}
function insertComment() { wrapTag('div', 'class="novela-comment"'); }
// ── Add / delete chapter ──────────────────────────────────────────────────────
async function addChapter() {
if (saving) return;
const title = prompt('Title for new page:', `New chapter ${Math.max(chapters.length + 1, 1)}`);
if (title === null) return;
const insertIdx = currentIndex < 0 ? chapters.length : currentIndex + 1;
const newCh = {
title: title.trim() || 'New chapter',
href: null,
_id: nextLocalId++,
_new: true,
_serverIndex: null,
};
chapters.splice(insertIdx, 0, newCh);
structureDirty = true;
currentIndex = insertIdx;
renderChapterList();
loadingChapter = true;
editor.setValue('');
editor.setScrollTop(0);
loadingChapter = false;
editor.focus();
document.getElementById('btn-save').disabled = false;
document.getElementById('btn-break').disabled = false;
document.getElementById('btn-subheading').disabled = false;
document.getElementById('btn-chat').disabled = false;
document.getElementById('btn-indent').disabled = false;
document.getElementById('btn-comment').disabled = false;
document.getElementById('btn-del-page').disabled = chapters.length <= 1;
setStatus('dirty', 'Unsaved changes');
document.getElementById('chapter-title-input').value = newCh.title;
updateSaveAll();
}
async function deleteChapter() {
if (saving || currentIndex < 0) return;
saving = true;
if (chapters.length <= 1) {
alert('Cannot delete the last page.');
saving = false;
return;
}
const ch = chapters[currentIndex];
const chTitle = ch.title || `chapter ${currentIndex + 1}`;
if (!confirm(`Delete page "${chTitle}"?`)) { saving = false; return; }
// Clean up pending state for this chapter
dirty.delete(ch._id);
pendingContent.delete(ch._id);
pendingTitles.delete(ch._id);
const removedIndex = currentIndex;
if (ch._new) {
// Never reached the server — just remove locally
chapters.splice(removedIndex, 1);
structureDirty = pendingDeletes.length > 0 || chapters.some(c => c._new);
} else {
pendingDeletes.push({ _serverIndex: ch._serverIndex, title: chTitle });
chapters.splice(removedIndex, 1);
structureDirty = true;
}
const newCount = chapters.length;
if (newCount === 0) {
currentIndex = -1;
renderChapterList();
if (!is_db) document.getElementById('header-chapter').textContent = 'No chapters';
document.getElementById('btn-save').disabled = !structureDirty;
if (structureDirty) setStatus('dirty', 'Unsaved changes');
document.getElementById('btn-break').disabled = true;
document.getElementById('btn-subheading').disabled = true;
document.getElementById('btn-chat').disabled = true;
document.getElementById('btn-indent').disabled = true;
document.getElementById('btn-comment').disabled = true;
document.getElementById('btn-del-page').disabled = true;
if (editor) { loadingChapter = true; editor.setValue(''); loadingChapter = false; }
saving = false;
updateSaveAll();
return;
}
const newIdx = Math.min(removedIndex, newCount - 1);
renderChapterList();
await loadChapter(newIdx);
setStatus('dirty', 'Unsaved changes');
saving = false;
}
// ── Generate Book Info page ───────────────────────────────────────────────────
async function generateIntroPage() {
if (saving) return;
if (structureDirty || dirty.size > 0) {
alert('Please save pending changes before generating the info page.');
return;
}
saving = true;
setStatus('saving', 'Generating info page…');
try {
const resp = await fetch(
`/api/edit/intro/${encodeURIComponent(filename)}`,
{ method: 'POST' }
);
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.ok) {
setStatus('error', data.error || 'Failed to generate info page');
saving = false;
return;
}
setStatus('saved', 'Info page added');
setTimeout(() => setStatus('', ''), 2000);
saving = false;
await loadChapterList(0);
} catch {
setStatus('error', 'Failed to generate info page');
saving = false;
}
}
// ── 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 currentOnly = document.getElementById('rp-current').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
const curCh = currentCh();
if (curCh) pendingContent.set(curCh._id, editor.getValue());
// Determine which chapters to process
let targets;
if (currentOnly) {
if (!curCh) {
prog.className = 'modal-progress error';
prog.textContent = 'No chapter open.';
runBtn.disabled = false;
return;
}
targets = [curCh];
} else {
targets = chapters;
}
for (let i = 0; i < targets.length; i++) {
const ch = targets[i];
prog.className = 'modal-progress';
prog.textContent = `Checking chapter ${i + 1} / ${targets.length}`;
let original;
if (pendingContent.has(ch._id)) {
original = pendingContent.get(ch._id);
} else if (ch._new) {
original = '';
} else {
try {
const resp = await fetch(`/api/edit/chapter/${ch._serverIndex}/${encodeURIComponent(filename)}`);
if (!resp.ok) continue;
const data = await resp.json();
original = data.content;
} catch {
continue;
}
}
let count = 0;
const updated = original.replace(pattern, m => { count++; return replaceVal; });
if (count === 0) continue;
totalOccurrences += count;
chaptersChanged++;
pendingContent.set(ch._id, updated);
dirty.add(ch._id);
}
// Reload current chapter from pending cache if it was changed
const cur = currentCh();
if (cur && dirty.has(cur._id) && pendingContent.has(cur._id)) {
loadingChapter = true;
editor.setValue(pendingContent.get(cur._id));
loadingChapter = false;
document.getElementById('btn-save').disabled = false;
setStatus('dirty', 'Unsaved changes');
}
renderChapterList();
updateSaveAll();
prog.className = totalOccurrences > 0 ? 'modal-progress ok' : 'modal-progress';
if (totalOccurrences === 0) {
prog.textContent = 'No matches found.';
} else if (currentOnly) {
prog.textContent = `${totalOccurrences} replacement${totalOccurrences !== 1 ? 's' : ''} in current chapter — not saved yet.`;
} else {
prog.textContent = `${totalOccurrences} replacement${totalOccurrences !== 1 ? 's' : ''} in ${chaptersChanged} chapter${chaptersChanged !== 1 ? 's' : ''} — not saved yet.`;
}
runBtn.disabled = false;
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function setStatus(cls, text) {
const el = document.getElementById('save-status');
el.className = 'save-status' + (cls ? ' ' + cls : '');
el.textContent = text;
}