Dev build 2026-06-12 23:10

This commit is contained in:
Ivo Oskamp 2026-06-12 23:10:46 +02:00
parent 7a3d5b4ed8
commit 9daf271a52
14 changed files with 768 additions and 5 deletions

View File

@ -0,0 +1,2 @@
node_modules/
package-lock.json

View File

@ -0,0 +1,25 @@
// Bundles the Tiptap visual editor into a single browser file that the editor
// page loads with a plain <script> tag — Novela's frontend has no build step at
// runtime, so the bundle is produced here and committed to ../static/.
//
// npm install && npm run build
//
// Output: ../static/editor-bundle.js (exposes window.NovelaVisual)
import { build } from 'esbuild';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
await build({
entryPoints: [resolve(here, 'index.js')],
bundle: true,
format: 'iife',
globalName: 'NovelaVisual',
target: ['es2019'],
minify: true,
legalComments: 'none',
outfile: resolve(here, '..', 'static', 'editor-bundle.js'),
});
console.log('Built ../static/editor-bundle.js');

View File

@ -0,0 +1,116 @@
// Novela's own chapter conventions modelled as a real ProseMirror schema, so the
// visual editor applies/round-trips them instead of showing raw tags.
//
// These mirror what the HTML (Monaco) toolbar produces in editor.js:
// subheading -> <span class="subheading">…</span>
// chat -> <span class="chat">…</span>
// comment -> <div class="novela-comment">…</div>
// indent -> <p style="padding-left: 40px;">…</p>
// scene break -> <center><img src="…break.png" style="height:15px;"/></center>
//
// Anything the schema does NOT model is dropped by ProseMirror on load — that is
// exactly why visual mode is gated behind the round-trip safety check in index.js.
import { Mark, Node, Extension, mergeAttributes } from '@tiptap/core';
export const Subheading = Mark.create({
name: 'subheading',
parseHTML() { return [{ tag: 'span.subheading' }]; },
renderHTML({ HTMLAttributes }) {
return ['span', mergeAttributes(HTMLAttributes, { class: 'subheading' }), 0];
},
addCommands() {
return { toggleSubheading: () => ({ commands }) => commands.toggleMark(this.name) };
},
});
export const Chat = Mark.create({
name: 'chat',
parseHTML() { return [{ tag: 'span.chat' }]; },
renderHTML({ HTMLAttributes }) {
return ['span', mergeAttributes(HTMLAttributes, { class: 'chat' }), 0];
},
addCommands() {
return { toggleChat: () => ({ commands }) => commands.toggleMark(this.name) };
},
});
export const Comment = Node.create({
name: 'novelaComment',
group: 'block',
content: 'inline*',
defining: true,
parseHTML() { return [{ tag: 'div.novela-comment' }]; },
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes, { class: 'novela-comment' }), 0];
},
addCommands() {
return {
toggleComment: () => ({ commands }) => commands.toggleNode(this.name, 'paragraph'),
};
},
});
// Scene break: <center><img src="…" style="height:15px;"/></center>. The img src
// differs between DB books (/static/break.png) and on-disk EPUBs
// (../Images/break.png), so the original src/style are preserved as attributes
// and reproduced verbatim, keeping the round-trip lossless.
export const SceneBreak = Node.create({
name: 'sceneBreak',
group: 'block',
atom: true,
selectable: true,
addAttributes() {
return {
src: { default: '/static/break.png' },
style: { default: 'height:15px;' },
};
},
parseHTML() {
return [{
tag: 'center',
getAttrs: (el) => {
const img = el.querySelector('img');
if (!img) return false; // a <center> without an image is not a scene break
return { src: img.getAttribute('src') || '/static/break.png',
style: img.getAttribute('style') || 'height:15px;' };
},
}];
},
renderHTML({ HTMLAttributes }) {
return ['center', {}, ['img', mergeAttributes({}, HTMLAttributes)]];
},
addCommands() {
return {
setSceneBreak: (src) => ({ commands }) =>
commands.insertContent({ type: this.name, attrs: { src, style: 'height:15px;' } }),
};
},
});
// Indent modelled as a paragraph style attribute so it round-trips the exact
// markup the HTML toolbar emits (<p style="padding-left: 40px;">).
export const Indent = Extension.create({
name: 'novelaIndent',
addGlobalAttributes() {
return [{
types: ['paragraph'],
attributes: {
indent: {
default: false,
parseHTML: (el) => /padding-left\s*:\s*40px/i.test(el.getAttribute('style') || ''),
renderHTML: (attrs) => (attrs.indent ? { style: 'padding-left: 40px;' } : {}),
},
},
}];
},
addCommands() {
return {
toggleIndent: () => ({ editor, commands }) => {
if (editor.state.selection.$from.parent.type.name !== 'paragraph') return false;
return commands.updateAttributes('paragraph', {
indent: !editor.getAttributes('paragraph').indent,
});
},
};
},
});

View File

@ -0,0 +1,123 @@
// Entry point for the Novela visual editor bundle. Exposed as window.NovelaVisual.
//
// NovelaVisual.createEditor(el, html, onChange, onSelection) -> Editor
// NovelaVisual.cleanListHTML(html) -> html (tighten <li><p>…</p></li>)
// NovelaVisual.roundtripSafe(html) -> { safe, reason }
//
// roundtripSafe is the gate for "detect & block": visual mode is only offered
// when feeding the chapter through ProseMirror and reading it back loses nothing.
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Underline from '@tiptap/extension-underline';
import Superscript from '@tiptap/extension-superscript';
import Subscript from '@tiptap/extension-subscript';
import { Subheading, Chat, Comment, SceneBreak, Indent } from './extensions.js';
function extensions() {
return [
StarterKit.configure({
heading: { levels: [2, 3] },
// Novela uses <center><img> for scene breaks, not <hr> — drop StarterKit's.
horizontalRule: false,
}),
Underline,
Superscript,
Subscript,
Indent,
Subheading,
Chat,
Comment,
SceneBreak,
];
}
// A list item must hold a block (<li><p>…</p></li>) while editing, but that inner
// <p> clutters stored markup. On serialize, unwrap a single attribute-less <p>
// that is an <li>'s only child -> flat <li>…</li>. ProseMirror re-wraps it on
// load, so the round-trip stays stable.
export function cleanListHTML(html) {
if (!html || html.indexOf('<li') === -1 || typeof DOMParser === 'undefined') return html;
const doc = new DOMParser().parseFromString(html, 'text/html');
doc.querySelectorAll('li').forEach((li) => {
const only = li.children.length === 1 ? li.firstElementChild : null;
if (only && only.tagName === 'P' && !only.hasAttributes()) {
while (only.firstChild) li.insertBefore(only.firstChild, only);
li.removeChild(only);
}
});
return doc.body.innerHTML;
}
export function createEditor(element, content, onChange, onSelection) {
return new Editor({
element,
extensions: extensions(),
content,
autofocus: 'start',
onUpdate: ({ editor }) => onChange && onChange(cleanListHTML(editor.getHTML())),
onSelectionUpdate: () => onSelection && onSelection(),
});
}
// ── Round-trip safety ─────────────────────────────────────────────────────────
// Canonicalise HTML to a form that ignores insignificant formatting differences
// (attribute order, whitespace, self-closing style, b/strong & i/em equivalence)
// but still reflects any tag/attribute/text the editor would DROP. If the
// canonical form of the original equals that of the same HTML after a ProseMirror
// round-trip, visual editing cannot silently lose content.
function normStyle(v) {
return (v || '').split(';').map(s => s.trim().replace(/\s*:\s*/, ':'))
.filter(Boolean).sort().join(';');
}
const TAG_EQUIV = { b: 'strong', i: 'em', strike: 's', del: 's' };
function canonical(html) {
if (typeof DOMParser === 'undefined') return html;
const doc = new DOMParser().parseFromString(html || '', 'text/html');
const out = [];
const walk = (node) => {
node.childNodes.forEach((child) => {
if (child.nodeType === 3) { // text
const t = child.nodeValue.replace(/\s+/g, ' ');
if (t.trim() !== '') out.push('#' + t.trim());
return;
}
if (child.nodeType !== 1) return; // ignore comments etc.
const tag = TAG_EQUIV[child.tagName.toLowerCase()] || child.tagName.toLowerCase();
const attrs = [...child.attributes].map((a) => {
if (a.name === 'style') return 'style=' + normStyle(a.value);
if (a.name === 'class') return 'class=' + a.value.split(/\s+/).filter(Boolean).sort().join(' ');
return a.name + '=' + a.value;
}).sort();
out.push('<' + tag + (attrs.length ? ' ' + attrs.join(' ') : '') + '>');
walk(child);
out.push('</' + tag + '>');
});
};
// Walk the whole document, not just <body>: EPUB chapters are full xhtml files,
// and their <head>/doctype/<html> wrapper would be lost by the editor (which only
// round-trips a body fragment). Comparing the full tree flags that as unsafe.
walk(doc.documentElement);
return out.join('');
}
export function roundtripSafe(html) {
if (!html || !html.trim()) return { safe: true, reason: '' };
const host = document.createElement('div');
host.style.display = 'none';
document.body.appendChild(host);
let after;
try {
const ed = new Editor({ element: host, extensions: extensions(), content: html });
after = cleanListHTML(ed.getHTML());
ed.destroy();
} catch (e) {
host.remove();
return { safe: false, reason: 'editor error: ' + (e && e.message || e) };
}
host.remove();
const safe = canonical(html) === canonical(after);
return { safe, reason: safe ? '' : 'content would be altered by the visual editor' };
}

View File

@ -0,0 +1,20 @@
{
"name": "novela-visual-editor",
"private": true,
"version": "1.0.0",
"description": "Tiptap-based visual (WYSIWYG) editing pane for the Novela chapter editor. Bundled to ../static/editor-bundle.js.",
"type": "module",
"scripts": {
"build": "node build.mjs"
},
"dependencies": {
"@tiptap/core": "^2.8.0",
"@tiptap/extension-subscript": "^2.8.0",
"@tiptap/extension-superscript": "^2.8.0",
"@tiptap/extension-underline": "^2.8.0",
"@tiptap/starter-kit": "^2.8.0"
},
"devDependencies": {
"esbuild": "^0.24.0"
}
}

View File

@ -0,0 +1,64 @@
// Verify the toolbar commands emit the exact Novela markup (and that the output
// is itself round-trip safe). Runs the real bundle under jsdom.
import { readFileSync } from 'node:fs';
import { JSDOM } from 'jsdom';
const dom = new JSDOM('<!DOCTYPE html><body></body>', { pretendToBeVisual: true });
for (const k of ['window', 'document', 'DOMParser', 'navigator', 'Node', 'Element',
'HTMLElement', 'Text', 'getComputedStyle', 'DocumentFragment', 'MutationObserver']) {
if (k === 'window') globalThis.window = dom.window;
else if (dom.window[k]) globalThis[k] = dom.window[k];
}
const code = readFileSync(new URL('../static/editor-bundle.js', import.meta.url), 'utf8');
new Function('window', 'document', 'navigator', code + '\nwindow.NovelaVisual = NovelaVisual;')(
dom.window, dom.window.document, dom.window.navigator);
const V = dom.window.NovelaVisual;
let pass = true;
const check = (name, cond, extra='') => { if (!cond) pass = false; console.log(`${cond?'PASS':'FAIL'} ${name}${extra?' '+extra:''}`); };
function fresh(html='<p>Hello world</p>') {
const host = dom.window.document.createElement('div');
dom.window.document.body.appendChild(host);
return V.createEditor(host, html, null, null);
}
// 1. Scene break command emits <center><img src=.../>
let ed = fresh('<p>One</p>');
ed.chain().setSceneBreak('/static/break.png').run();
let out = V.cleanListHTML(ed.getHTML());
check('setSceneBreak emits center>img', /<center><img[^>]*src="\/static\/break\.png"/.test(out), out.slice(0,120));
check('scene break output is round-trip safe', V.roundtripSafe(out).safe);
ed.destroy();
// 2. Comment command turns a block into div.novela-comment
ed = fresh('<p>note text</p>');
ed.chain().selectAll().toggleComment().run();
out = V.cleanListHTML(ed.getHTML());
check('toggleComment emits div.novela-comment', /<div class="novela-comment">note text<\/div>/.test(out), out);
check('comment output round-trip safe', V.roundtripSafe(out).safe);
ed.destroy();
// 3. Indent command adds padding-left:40px to paragraph
ed = fresh('<p>indent me</p>');
ed.chain().selectAll().toggleIndent().run();
out = V.cleanListHTML(ed.getHTML());
check('toggleIndent emits padding-left:40px', /padding-left:\s*40px/.test(out), out);
ed.destroy();
// 4. Subheading mark
ed = fresh('<p>sub</p>');
ed.chain().selectAll().toggleSubheading().run();
out = V.cleanListHTML(ed.getHTML());
check('toggleSubheading emits span.subheading', /<span class="subheading">sub<\/span>/.test(out), out);
ed.destroy();
// 5. Bold + bullet list standard commands still work
ed = fresh('<p>x</p>');
ed.chain().selectAll().toggleBold().run();
out = V.cleanListHTML(ed.getHTML());
check('toggleBold emits <strong>', /<strong>x<\/strong>/.test(out), out);
ed.destroy();
console.log('\n' + (pass ? 'ALL PASS' : 'SOME FAILED'));
process.exit(pass ? 0 : 1);

View File

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>roundtrip test</title></head>
<body>
<script src="../static/editor-bundle.js"></script>
<script>
const V = window.NovelaVisual;
const cases = [
{ name: 'clean novela conventions', safe: true, html:
'<p>Intro paragraph.</p>' +
'<p><span class="subheading">A subheading</span></p>' +
'<p><span class="chat">"Hi there"</span></p>' +
'<div class="novela-comment">An author note</div>' +
'<p style="padding-left: 40px;">Indented.</p>' +
'<center><img src="/static/break.png" style="height:15px;"/></center>' +
'<h2>Heading</h2>' +
'<ul><li>one</li><li>two</li></ul>' },
{ name: 'b/i equivalence to strong/em', safe: true, html:
'<p><b>bold</b> and <i>italic</i> and <u>under</u></p>' },
{ name: 'epub break src preserved', safe: true, html:
'<p>Before</p><center><img src="../Images/break.png" style="height:15px;"/></center><p>After</p>' },
{ name: 'unknown table tag dropped', safe: false, html:
'<p>Hello</p><table><tbody><tr><td>x</td></tr></tbody></table>' },
{ name: 'full xhtml document', safe: false, html:
'<html><head><title>x</title></head><body><p>Hi</p></body></html>' },
{ name: 'inline color span dropped', safe: false, html:
'<p><span style="color:red">red text</span></p>' },
{ name: 'empty content', safe: true, html: '' },
];
const results = cases.map(c => {
const r = V.roundtripSafe(c.html);
return { name: c.name, expected: c.safe, got: r.safe, pass: r.safe === c.safe, reason: r.reason };
});
const allPass = results.every(r => r.pass);
window.__RESULTS__ = { allPass, results };
document.title = allPass ? 'ALL PASS' : 'FAIL';
console.log(JSON.stringify({ allPass, results }, null, 2));
</script>
</body></html>

View File

@ -0,0 +1,51 @@
// Headless test of roundtripSafe / round-trip fidelity using jsdom.
import { readFileSync } from 'node:fs';
import { JSDOM } from 'jsdom';
const dom = new JSDOM('<!DOCTYPE html><body></body>', { pretendToBeVisual: true });
globalThis.window = dom.window;
globalThis.document = dom.window.document;
globalThis.DOMParser = dom.window.DOMParser;
globalThis.navigator = dom.window.navigator;
for (const k of ['Node', 'Element', 'HTMLElement', 'Text', 'getComputedStyle', 'DocumentFragment', 'MutationObserver']) {
if (dom.window[k]) globalThis[k] = dom.window[k];
}
// Load the IIFE bundle into this context.
const code = readFileSync(new URL('../static/editor-bundle.js', import.meta.url), 'utf8');
new Function('window', 'document', 'navigator', code + '\nwindow.NovelaVisual = NovelaVisual;')(
dom.window, dom.window.document, dom.window.navigator);
const V = dom.window.NovelaVisual;
const cases = [
{ name: 'clean novela conventions', safe: true, html:
'<p>Intro paragraph.</p>' +
'<p><span class="subheading">A subheading</span></p>' +
'<p><span class="chat">"Hi there"</span></p>' +
'<div class="novela-comment">An author note</div>' +
'<p style="padding-left: 40px;">Indented.</p>' +
'<center><img src="/static/break.png" style="height:15px;"/></center>' +
'<h2>Heading</h2>' +
'<ul><li>one</li><li>two</li></ul>' },
{ name: 'b/i equivalence to strong/em', safe: true, html:
'<p><b>bold</b> and <i>italic</i> and <u>under</u></p>' },
{ name: 'epub break src preserved', safe: true, html:
'<p>Before</p><center><img src="../Images/break.png" style="height:15px;"/></center><p>After</p>' },
{ name: 'unknown table tag dropped', safe: false, html:
'<p>Hello</p><table><tbody><tr><td>x</td></tr></tbody></table>' },
{ name: 'full xhtml document', safe: false, html:
'<html><head><title>x</title></head><body><p>Hi</p></body></html>' },
{ name: 'inline color span dropped', safe: false, html:
'<p><span style="color:red">red text</span></p>' },
{ name: 'empty content', safe: true, html: '' },
];
let allPass = true;
for (const c of cases) {
const r = V.roundtripSafe(c.html);
const pass = r.safe === c.safe;
if (!pass) allPass = false;
console.log(`${pass ? 'PASS' : 'FAIL'} ${c.name} (expected safe=${c.safe}, got=${r.safe}${r.reason ? ', ' + r.reason : ''})`);
}
console.log('\n' + (allPass ? 'ALL PASS' : 'SOME FAILED'));
process.exit(allPass ? 0 : 1);

File diff suppressed because one or more lines are too long

View File

@ -248,3 +248,50 @@ html, body { height: 100%; background: var(--bg); color: var(--text); font-famil
/* ── Monaco container ── */ /* ── Monaco container ── */
.editor-pane { flex: 1; overflow: hidden; } .editor-pane { flex: 1; overflow: hidden; }
/* ── Visual (WYSIWYG) toggle + toolbar ── */
.btn-visual {
padding: 0.3rem 0.7rem;
background: none; border: 1px solid var(--border); border-radius: var(--radius);
font-family: var(--mono); font-size: 0.68rem;
color: var(--text-dim); cursor: pointer;
transition: color 0.12s, border-color 0.12s, background 0.12s;
}
.btn-visual:disabled { opacity: 0.3; cursor: not-allowed; }
.btn-visual:not(:disabled):hover { color: var(--text); border-color: var(--text-faint); }
.btn-visual.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.visual-tools { display: flex; align-items: center; gap: 0.35rem; }
.vbtn {
min-width: 1.7rem; padding: 0.3rem 0.45rem;
background: none; border: 1px solid var(--border); border-radius: var(--radius);
font-family: var(--mono); font-size: 0.68rem;
color: var(--text-dim); cursor: pointer;
transition: color 0.12s, border-color 0.12s, background 0.12s;
}
.vbtn:hover { color: var(--text); border-color: var(--text-faint); }
.vbtn.active { background: var(--surface2); color: var(--text); border-color: var(--text-faint); }
/* ── Visual editing pane ── */
.visual-pane { flex: 1; overflow-y: auto; background: var(--bg); }
.visual-pane .ProseMirror {
max-width: 42rem; margin: 0 auto; padding: 2.5rem 1.5rem 6rem;
outline: none; color: var(--text);
font-family: var(--serif, Georgia, serif); font-size: 1.05rem; line-height: 1.7;
}
.visual-pane .ProseMirror p { margin: 0 0 1em; }
.visual-pane .ProseMirror h2 { font-size: 1.5rem; margin: 1.2em 0 0.6em; }
.visual-pane .ProseMirror h3 { font-size: 1.2rem; margin: 1em 0 0.5em; }
.visual-pane .ProseMirror ul,
.visual-pane .ProseMirror ol { margin: 0 0 1em 1.5em; }
.visual-pane .ProseMirror center { text-align: center; margin: 1.4em 0; }
/* Novela conventions, mirrored from epub-style.css so they read correctly here */
.visual-pane .ProseMirror span.subheading { color: rgb(224,62,45); font-weight: bold; }
.visual-pane .ProseMirror span.chat { color: rgb(230,126,35); }
.visual-pane .ProseMirror div.novela-comment {
font-style: italic; color: var(--text-dim);
border-left: 3px solid var(--border); padding-left: 1rem; margin: 1em 0;
}
.visual-pane .ProseMirror .ProseMirror-selectednode {
outline: 2px solid var(--accent); outline-offset: 2px;
}

View File

@ -16,6 +16,11 @@ let structureDirty = false; // pending adds or deletes not yet on server
let loadingChapter = false; let loadingChapter = false;
let saving = 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; } function currentCh() { return currentIndex >= 0 ? chapters[currentIndex] : null; }
// ── Init Monaco ─────────────────────────────────────────────────────────────── // ── Init Monaco ───────────────────────────────────────────────────────────────
@ -96,6 +101,8 @@ async function loadChapterList(targetIndex = 0) {
document.getElementById('btn-comment').disabled = true; document.getElementById('btn-comment').disabled = true;
document.getElementById('btn-del-page').disabled = true; document.getElementById('btn-del-page').disabled = true;
if (editor) { loadingChapter = true; editor.setValue(''); loadingChapter = false; } if (editor) { loadingChapter = true; editor.setValue(''); loadingChapter = false; }
if (mode === 'visual') { destroyVisual(); showPane('html'); }
document.getElementById('btn-visual').disabled = true;
updateSaveAll(); updateSaveAll();
return; return;
} }
@ -122,6 +129,7 @@ function renderChapterList() {
async function switchChapter(index) { async function switchChapter(index) {
if (index === currentIndex) return; if (index === currentIndex) return;
syncVisualToMonaco(); // pull any visual edits into Monaco before flushing
// Flush current content/title to pending cache before switching // Flush current content/title to pending cache before switching
const ch = currentCh(); const ch = currentCh();
if (ch) { if (ch) {
@ -181,6 +189,8 @@ async function loadChapter(index) {
document.getElementById('btn-indent').disabled = false; document.getElementById('btn-indent').disabled = false;
document.getElementById('btn-comment').disabled = false; document.getElementById('btn-comment').disabled = false;
document.getElementById('btn-del-page').disabled = chapters.length <= 1; document.getElementById('btn-del-page').disabled = chapters.length <= 1;
document.getElementById('btn-visual').disabled = false;
refreshVisualAfterLoad();
updateSaveAll(); updateSaveAll();
} }
@ -189,6 +199,7 @@ async function loadChapter(index) {
async function saveChapter() { async function saveChapter() {
if (saving) return; if (saving) return;
saving = true; saving = true;
syncVisualToMonaco();
document.getElementById('btn-save').disabled = true; document.getElementById('btn-save').disabled = true;
// Apply structural changes (add/delete) before saving content // Apply structural changes (add/delete) before saving content
@ -255,6 +266,7 @@ async function saveAllChapters() {
if (btn) btn.disabled = true; if (btn) btn.disabled = true;
setStatus('saving', 'Saving all…'); setStatus('saving', 'Saving all…');
syncVisualToMonaco();
// Flush current editor content and title into pending caches first // Flush current editor content and title into pending caches first
const ch = currentCh(); const ch = currentCh();
if (ch && dirty.has(ch._id)) { if (ch && dirty.has(ch._id)) {
@ -391,8 +403,10 @@ async function applyStructuralChanges() {
// ── Insert break ────────────────────────────────────────────────────────────── // ── Insert break ──────────────────────────────────────────────────────────────
function insertBreak() { function insertBreak() {
if (!editor || currentIndex < 0) return; if (currentIndex < 0) return;
const breakSrc = is_db ? '/static/break.png' : '../Images/break.png'; 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(); const pos = editor.getPosition();
editor.executeEdits('insert-break', [{ editor.executeEdits('insert-break', [{
range: new monaco.Range(pos.lineNumber, pos.column, pos.lineNumber, pos.column), range: new monaco.Range(pos.lineNumber, pos.column, pos.lineNumber, pos.column),
@ -427,7 +441,14 @@ function wrapTag(tag, attrs) {
// elements (<p>, <div>, <h*>) the span is replaced by a <div> so the result // elements (<p>, <div>, <h*>) the span is replaced by a <div> so the result
// stays valid HTML. // stays valid HTML.
function wrapSpan(cls) { function wrapSpan(cls) {
if (!editor || currentIndex < 0) return; 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 sel = editor.getSelection();
const selectedText = editor.getModel().getValueInRange(sel); const selectedText = editor.getModel().getValueInRange(sel);
const hasBlock = /<(p|div|h[1-6]|blockquote|ul|ol|li)[\s>]/i.test(selectedText); const hasBlock = /<(p|div|h[1-6]|blockquote|ul|ol|li)[\s>]/i.test(selectedText);
@ -436,7 +457,9 @@ function wrapSpan(cls) {
} }
function insertIndent() { function insertIndent() {
if (!editor || currentIndex < 0) return; if (currentIndex < 0) return;
if (mode === 'visual') { visualEditor.chain().focus().toggleIndent().run(); updateVisualButtons(); return; }
if (!editor) return;
const sel = editor.getSelection(); const sel = editor.getSelection();
const selectedText = editor.getModel().getValueInRange(sel); const selectedText = editor.getModel().getValueInRange(sel);
const hasBlock = /<(p|div|h[1-6]|blockquote|ul|ol|li)[\s>]/i.test(selectedText); const hasBlock = /<(p|div|h[1-6]|blockquote|ul|ol|li)[\s>]/i.test(selectedText);
@ -445,7 +468,11 @@ function insertIndent() {
wrapTag(tag, 'style="padding-left: 40px;"'); wrapTag(tag, 'style="padding-left: 40px;"');
} }
function insertComment() { wrapTag('div', 'class="novela-comment"'); } function insertComment() {
if (currentIndex < 0) return;
if (mode === 'visual') { visualEditor.chain().focus().toggleComment().run(); updateVisualButtons(); return; }
wrapTag('div', 'class="novela-comment"');
}
// ── Add / delete chapter ────────────────────────────────────────────────────── // ── Add / delete chapter ──────────────────────────────────────────────────────
@ -481,6 +508,8 @@ async function addChapter() {
document.getElementById('btn-indent').disabled = false; document.getElementById('btn-indent').disabled = false;
document.getElementById('btn-comment').disabled = false; document.getElementById('btn-comment').disabled = false;
document.getElementById('btn-del-page').disabled = chapters.length <= 1; document.getElementById('btn-del-page').disabled = chapters.length <= 1;
document.getElementById('btn-visual').disabled = false;
refreshVisualAfterLoad();
setStatus('dirty', 'Unsaved changes'); setStatus('dirty', 'Unsaved changes');
document.getElementById('chapter-title-input').value = newCh.title; document.getElementById('chapter-title-input').value = newCh.title;
updateSaveAll(); updateSaveAll();
@ -529,6 +558,8 @@ async function deleteChapter() {
document.getElementById('btn-comment').disabled = true; document.getElementById('btn-comment').disabled = true;
document.getElementById('btn-del-page').disabled = true; document.getElementById('btn-del-page').disabled = true;
if (editor) { loadingChapter = true; editor.setValue(''); loadingChapter = false; } if (editor) { loadingChapter = true; editor.setValue(''); loadingChapter = false; }
if (mode === 'visual') { destroyVisual(); showPane('html'); }
document.getElementById('btn-visual').disabled = true;
saving = false; saving = false;
updateSaveAll(); updateSaveAll();
return; return;
@ -618,6 +649,7 @@ async function replaceInAllChapters() {
let chaptersChanged = 0; let chaptersChanged = 0;
// Flush current editor content into pending before we start // Flush current editor content into pending before we start
syncVisualToMonaco();
const curCh = currentCh(); const curCh = currentCh();
if (curCh) pendingContent.set(curCh._id, editor.getValue()); if (curCh) pendingContent.set(curCh._id, editor.getValue());
@ -672,6 +704,7 @@ async function replaceInAllChapters() {
loadingChapter = true; loadingChapter = true;
editor.setValue(pendingContent.get(cur._id)); editor.setValue(pendingContent.get(cur._id));
loadingChapter = false; loadingChapter = false;
refreshVisualAfterLoad(); // re-gate: replaced markup may no longer be visual-safe
document.getElementById('btn-save').disabled = false; document.getElementById('btn-save').disabled = false;
setStatus('dirty', 'Unsaved changes'); setStatus('dirty', 'Unsaved changes');
} }
@ -690,6 +723,133 @@ async function replaceInAllChapters() {
runBtn.disabled = false; 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 vHeading(level) {
if (mode !== 'visual' || !visualEditor) return;
visualEditor.chain().focus().toggleHeading({ level }).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-h2', visualEditor.isActive('heading', { level: 2 }));
set('vb-h3', visualEditor.isActive('heading', { level: 3 }));
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 ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
function setStatus(cls, text) { function setStatus(cls, text) {

View File

@ -50,6 +50,18 @@
<button class="btn-chat" id="btn-chat" onclick="wrapSpan('chat')" title="Wrap selection as chat" disabled>C</button> <button class="btn-chat" id="btn-chat" onclick="wrapSpan('chat')" title="Wrap selection as chat" disabled>C</button>
<button class="btn-indent" id="btn-indent" onclick="insertIndent()" title="Wrap selection as indented paragraph" disabled>→|</button> <button class="btn-indent" id="btn-indent" onclick="insertIndent()" title="Wrap selection as indented paragraph" disabled>→|</button>
<button class="btn-comment" id="btn-comment" onclick="insertComment()" title="Wrap selection as author comment block" disabled>[ ]</button> <button class="btn-comment" id="btn-comment" onclick="insertComment()" title="Wrap selection as author comment block" disabled>[ ]</button>
<!-- Visual-only rich-text buttons (shown only in visual/WYSIWYG mode) -->
<span class="visual-tools" id="visual-tools" style="display:none">
<button class="vbtn" id="vb-bold" onclick="vCmd('toggleBold')" title="Bold"><b>B</b></button>
<button class="vbtn" id="vb-italic" onclick="vCmd('toggleItalic')" title="Italic"><i>I</i></button>
<button class="vbtn" id="vb-underline" onclick="vCmd('toggleUnderline')" title="Underline"><u>U</u></button>
<button class="vbtn" id="vb-h2" onclick="vHeading(2)" title="Heading 2">H2</button>
<button class="vbtn" id="vb-h3" onclick="vHeading(3)" title="Heading 3">H3</button>
<button class="vbtn" id="vb-ul" onclick="vCmd('toggleBulletList')" title="Bullet list"></button>
<button class="vbtn" id="vb-ol" onclick="vCmd('toggleOrderedList')" title="Numbered list">1.</button>
</span>
<button class="btn-visual" id="btn-visual" onclick="toggleVisual()" title="Toggle visual (WYSIWYG) editing" disabled>Visual</button>
<button class="btn-info-page" onclick="generateIntroPage()" title="Generate a Book Info page as the first chapter"> <button class="btn-info-page" onclick="generateIntroPage()" title="Generate a Book Info page as the first chapter">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="9"/> <circle cx="12" cy="12" r="9"/>
@ -77,6 +89,7 @@
<div class="chapter-list" id="chapter-list"></div> <div class="chapter-list" id="chapter-list"></div>
</nav> </nav>
<div class="editor-pane" id="editor-pane"></div> <div class="editor-pane" id="editor-pane"></div>
<div class="visual-pane" id="visual-pane" style="display:none"></div>
</div> </div>
<!-- Find & Replace modal --> <!-- Find & Replace modal -->
@ -112,6 +125,7 @@
}; };
</script> </script>
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js"></script> <script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js"></script>
<script src="/static/editor-bundle.js"></script>
<script src="/static/books.js"></script> <script src="/static/books.js"></script>
<script src="/static/editor.js"></script> <script src="/static/editor.js"></script>
</body> </body>

View File

@ -10,7 +10,7 @@ from __future__ import annotations
from changelog import CHANGELOG from changelog import CHANGELOG
BUILD = 0 BUILD = 1
def _release_version() -> str: def _release_version() -> str:

View File

@ -1,5 +1,15 @@
# Develop Changelog # Develop Changelog
## 2026-06-12 — Chapter editor: optional visual (WYSIWYG) mode alongside Monaco
### Added
- The chapter editor now has an optional **Visual (WYSIWYG) mode** next to the existing Monaco HTML-source editor, ported from the Tiptap editor built in novela-ng and adapted to Novela's own markup conventions. A **Visual / HTML** toggle button in the toolbar switches between the two; Monaco remains the default and the backing source of truth.
- **Tiptap is bundled, not CDN-loaded.** A new `containers/novela/editor-src/` npm project bundles Tiptap + the custom extensions into a single committed file, `static/editor-bundle.js` (`window.NovelaVisual`), loaded by the editor page with a plain `<script>` tag — the runtime frontend stays build-less. Rebuild with `cd containers/novela/editor-src && npm install && npm run build`. `node_modules` is git-ignored; the bundle is committed.
- **Novela conventions modelled as a real ProseMirror schema** so they apply and round-trip as formatting instead of raw tags: subheading (`span.subheading`), chat (`span.chat`), author comment (`div.novela-comment`), indented paragraph (`<p style="padding-left: 40px;">`), and scene break (`<center><img src="…break.png" style="height:15px;"/></center>` — the original img `src` is preserved, so both DB `/static/break.png` and on-disk EPUB `../Images/break.png` breaks round-trip exactly). Standard bold/italic/underline/superscript/subscript, H2/H3 headings, and bullet/numbered lists are also available as visual-only toolbar buttons.
- **Detect & block (no silent data loss).** ProseMirror drops any markup its schema doesn't model, which would silently strip formatting from arbitrary EPUB/DB HTML. Switching into Visual mode is therefore gated by a round-trip safety check (`NovelaVisual.roundtripSafe`): the chapter is fed through the editor and the result is compared, canonicalised, against the original (ignoring insignificant formatting differences such as attribute order, whitespace, self-closing style, and `b`/`strong` & `i`/`em` equivalence). If anything — a tag, attribute, or text — would be altered or dropped, Visual mode is refused for that chapter and editing stays in HTML mode. In practice this means Visual mode is offered for clean, convention-conforming content (typically DB books) and declined for heterogeneous EPUB markup or whole-xhtml documents, so a book's existing formatting can never be quietly destroyed.
- The semantic toolbar buttons (Break, S, C, →|, [ ]) route to the corresponding Tiptap commands in Visual mode and to the existing Monaco text-wrapping in HTML mode. Visual edits participate in the existing per-chapter dirty-tracking, Save / Save-all, chapter switching, add/delete, and Find & Replace flows; the visual content is synced back into Monaco (the backing store) at every save/switch/replace boundary, and the safety gate is re-evaluated whenever a chapter is loaded. Ctrl/Cmd+S also saves while editing visually.
- Files: new `containers/novela/editor-src/{package.json,build.mjs,index.js,extensions.js,test.mjs,test-commands.mjs,test.html,.gitignore}`; new committed `static/editor-bundle.js`; `templates/editor.html` (bundle script, toggle button, visual-only toolbar group, `#visual-pane`); `static/editor.css` (visual pane, toolbar and convention styling); `static/editor.js` (pane abstraction, `toggleVisual`/`mountVisual`/`syncVisualToMonaco`/`refreshVisualAfterLoad`, boundary syncing, toolbar routing). No backend changes — chapter save is unchanged and does not sanitise, so the editor's clean output is stored as-is.
## 2026-06-03 — Reader: Page Up/Down scroll within the page ## 2026-06-03 — Reader: Page Up/Down scroll within the page
### Changed ### Changed