novela/containers/novela/static/editor.js

854 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 cant 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;
}