854 lines
31 KiB
JavaScript
854 lines
31 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;
|
||
|
||
// Visual (WYSIWYG) editing. Monaco stays the backing store / source of truth;
|
||
// the visual editor is an optional overlay synced back into Monaco at boundaries.
|
||
let visualEditor = null; // Tiptap instance (window.NovelaVisual), or null
|
||
let mode = 'html'; // 'html' = Monaco, 'visual' = Tiptap
|
||
|
||
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; }
|
||
if (mode === 'visual') { destroyVisual(); showPane('html'); }
|
||
document.getElementById('btn-visual').disabled = true;
|
||
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;
|
||
syncVisualToMonaco(); // pull any visual edits into Monaco before flushing
|
||
// 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;
|
||
document.getElementById('btn-visual').disabled = false;
|
||
refreshVisualAfterLoad();
|
||
updateSaveAll();
|
||
}
|
||
|
||
// ── Save (current chapter) ────────────────────────────────────────────────────
|
||
|
||
async function saveChapter() {
|
||
if (saving) return;
|
||
saving = true;
|
||
syncVisualToMonaco();
|
||
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…');
|
||
|
||
syncVisualToMonaco();
|
||
// 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 (currentIndex < 0) return;
|
||
const breakSrc = is_db ? '/static/break.png' : '../Images/break.png';
|
||
if (mode === 'visual') { visualEditor.chain().focus().setSceneBreak(breakSrc).run(); return; }
|
||
if (!editor) 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="${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 (currentIndex < 0) return;
|
||
if (mode === 'visual') {
|
||
if (cls === 'subheading') visualEditor.chain().focus().toggleSubheading().run();
|
||
else if (cls === 'chat') visualEditor.chain().focus().toggleChat().run();
|
||
updateVisualButtons();
|
||
return;
|
||
}
|
||
if (!editor) 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 (currentIndex < 0) return;
|
||
if (mode === 'visual') { visualEditor.chain().focus().toggleIndent().run(); updateVisualButtons(); return; }
|
||
if (!editor) 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() {
|
||
if (currentIndex < 0) return;
|
||
if (mode === 'visual') { visualEditor.chain().focus().toggleComment().run(); updateVisualButtons(); return; }
|
||
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;
|
||
document.getElementById('btn-visual').disabled = false;
|
||
refreshVisualAfterLoad();
|
||
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; }
|
||
if (mode === 'visual') { destroyVisual(); showPane('html'); }
|
||
document.getElementById('btn-visual').disabled = true;
|
||
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
|
||
syncVisualToMonaco();
|
||
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;
|
||
refreshVisualAfterLoad(); // re-gate: replaced markup may no longer be visual-safe
|
||
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;
|
||
}
|
||
|
||
// ── Visual (WYSIWYG) mode ─────────────────────────────────────────────────────
|
||
|
||
const V = () => window.NovelaVisual;
|
||
|
||
function visualGetHTML() {
|
||
return V().cleanListHTML(visualEditor.getHTML());
|
||
}
|
||
|
||
// Pull current visual content into Monaco (the backing store) without marking
|
||
// dirty. Call before any code that reads editor.getValue() while visual is active.
|
||
function syncVisualToMonaco() {
|
||
if (mode !== 'visual' || !visualEditor) return;
|
||
loadingChapter = true;
|
||
editor.setValue(visualGetHTML());
|
||
loadingChapter = false;
|
||
}
|
||
|
||
// A visual-editor edit dirties the current chapter, exactly like a Monaco edit.
|
||
function markDirtyFromVisual() {
|
||
const ch = currentCh();
|
||
if (!ch) return;
|
||
dirty.add(ch._id);
|
||
renderChapterList();
|
||
setStatus('dirty', 'Unsaved changes');
|
||
document.getElementById('btn-save').disabled = false;
|
||
updateSaveAll();
|
||
}
|
||
|
||
function mountVisual(html) {
|
||
const el = document.getElementById('visual-pane');
|
||
el.innerHTML = '';
|
||
visualEditor = V().createEditor(el, html, markDirtyFromVisual, updateVisualButtons);
|
||
mode = 'visual';
|
||
}
|
||
|
||
function destroyVisual() {
|
||
if (visualEditor) { visualEditor.destroy(); visualEditor = null; }
|
||
mode = 'html';
|
||
}
|
||
|
||
function showPane(which) {
|
||
document.getElementById('editor-pane').style.display = which === 'html' ? '' : 'none';
|
||
document.getElementById('visual-pane').style.display = which === 'visual' ? '' : 'none';
|
||
document.getElementById('visual-tools').style.display = which === 'visual' ? 'flex' : 'none';
|
||
const btn = document.getElementById('btn-visual');
|
||
btn.textContent = which === 'visual' ? 'HTML' : 'Visual';
|
||
btn.classList.toggle('active', which === 'visual');
|
||
if (which === 'html' && editor) setTimeout(() => editor.layout(), 0);
|
||
}
|
||
|
||
// Toggle between Monaco (HTML source) and Tiptap (visual). Switching INTO visual
|
||
// is gated by the round-trip safety check — if the chapter's markup can't survive
|
||
// the visual editor losslessly, we refuse and stay in HTML mode (detect & block).
|
||
function toggleVisual() {
|
||
if (currentIndex < 0) return;
|
||
if (mode === 'visual') {
|
||
syncVisualToMonaco();
|
||
destroyVisual();
|
||
showPane('html');
|
||
editor.focus();
|
||
return;
|
||
}
|
||
const html = editor.getValue();
|
||
const check = V().roundtripSafe(html);
|
||
if (!check.safe) {
|
||
const wasDirty = dirty.has(currentCh()?._id);
|
||
setStatus('error', 'Visual mode unavailable here — chapter uses markup the visual editor can’t edit without changing it. Use HTML mode.');
|
||
setTimeout(() => setStatus(wasDirty ? 'dirty' : '', wasDirty ? 'Unsaved changes' : ''), 5000);
|
||
return;
|
||
}
|
||
mountVisual(html);
|
||
showPane('visual');
|
||
}
|
||
|
||
// After a chapter is (re)loaded into Monaco, refresh the visual editor if active.
|
||
// Re-runs the safety gate: a newly loaded chapter may not be visual-editable, in
|
||
// which case we drop back to HTML mode automatically.
|
||
function refreshVisualAfterLoad() {
|
||
if (mode !== 'visual') return;
|
||
const html = editor.getValue();
|
||
if (!V().roundtripSafe(html).safe) {
|
||
destroyVisual();
|
||
showPane('html');
|
||
return;
|
||
}
|
||
visualEditor.commands.setContent(html, false); // emitUpdate=false → no false dirty
|
||
}
|
||
|
||
// Visual-only toolbar commands.
|
||
function vCmd(name) {
|
||
if (mode !== 'visual' || !visualEditor) return;
|
||
visualEditor.chain().focus()[name]().run();
|
||
updateVisualButtons();
|
||
}
|
||
function updateVisualButtons() {
|
||
if (mode !== 'visual' || !visualEditor) return;
|
||
const set = (id, active) => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.classList.toggle('active', !!active);
|
||
};
|
||
set('vb-bold', visualEditor.isActive('bold'));
|
||
set('vb-italic', visualEditor.isActive('italic'));
|
||
set('vb-underline', visualEditor.isActive('underline'));
|
||
set('vb-sup', visualEditor.isActive('superscript'));
|
||
set('vb-sub', visualEditor.isActive('subscript'));
|
||
set('vb-ul', visualEditor.isActive('bulletList'));
|
||
set('vb-ol', visualEditor.isActive('orderedList'));
|
||
set('btn-subheading', visualEditor.isActive('subheading'));
|
||
set('btn-chat', visualEditor.isActive('chat'));
|
||
set('btn-comment', visualEditor.isActive('novelaComment'));
|
||
set('btn-indent', visualEditor.isActive('paragraph', { indent: true }));
|
||
}
|
||
|
||
// Ctrl/Cmd+S while editing visually (Monaco's own binding doesn't fire here).
|
||
document.addEventListener('keydown', (e) => {
|
||
if (mode === 'visual' && (e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
|
||
e.preventDefault();
|
||
saveChapter();
|
||
}
|
||
});
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
function setStatus(cls, text) {
|
||
const el = document.getElementById('save-status');
|
||
el.className = 'save-status' + (cls ? ' ' + cls : '');
|
||
el.textContent = text;
|
||
}
|