Dev build 2026-06-12 23:10
This commit is contained in:
parent
7a3d5b4ed8
commit
9daf271a52
2
containers/novela/editor-src/.gitignore
vendored
Normal file
2
containers/novela/editor-src/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
package-lock.json
|
||||
25
containers/novela/editor-src/build.mjs
Normal file
25
containers/novela/editor-src/build.mjs
Normal 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');
|
||||
116
containers/novela/editor-src/extensions.js
Normal file
116
containers/novela/editor-src/extensions.js
Normal 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
123
containers/novela/editor-src/index.js
Normal file
123
containers/novela/editor-src/index.js
Normal 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' };
|
||||
}
|
||||
20
containers/novela/editor-src/package.json
Normal file
20
containers/novela/editor-src/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
64
containers/novela/editor-src/test-commands.mjs
Normal file
64
containers/novela/editor-src/test-commands.mjs
Normal 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);
|
||||
38
containers/novela/editor-src/test.html
Normal file
38
containers/novela/editor-src/test.html
Normal 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>
|
||||
51
containers/novela/editor-src/test.mjs
Normal file
51
containers/novela/editor-src/test.mjs
Normal 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);
|
||||
93
containers/novela/static/editor-bundle.js
Normal file
93
containers/novela/static/editor-bundle.js
Normal file
File diff suppressed because one or more lines are too long
@ -248,3 +248,50 @@ html, body { height: 100%; background: var(--bg); color: var(--text); font-famil
|
||||
|
||||
/* ── Monaco container ── */
|
||||
.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;
|
||||
}
|
||||
|
||||
@ -16,6 +16,11 @@ 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 ───────────────────────────────────────────────────────────────
|
||||
@ -96,6 +101,8 @@ async function loadChapterList(targetIndex = 0) {
|
||||
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;
|
||||
}
|
||||
@ -122,6 +129,7 @@ function renderChapterList() {
|
||||
|
||||
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) {
|
||||
@ -181,6 +189,8 @@ async function loadChapter(index) {
|
||||
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();
|
||||
}
|
||||
|
||||
@ -189,6 +199,7 @@ async function loadChapter(index) {
|
||||
async function saveChapter() {
|
||||
if (saving) return;
|
||||
saving = true;
|
||||
syncVisualToMonaco();
|
||||
document.getElementById('btn-save').disabled = true;
|
||||
|
||||
// Apply structural changes (add/delete) before saving content
|
||||
@ -255,6 +266,7 @@ async function saveAllChapters() {
|
||||
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)) {
|
||||
@ -391,8 +403,10 @@ async function applyStructuralChanges() {
|
||||
// ── Insert break ──────────────────────────────────────────────────────────────
|
||||
|
||||
function insertBreak() {
|
||||
if (!editor || currentIndex < 0) return;
|
||||
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),
|
||||
@ -427,7 +441,14 @@ function wrapTag(tag, attrs) {
|
||||
// elements (<p>, <div>, <h*>) the span is replaced by a <div> so the result
|
||||
// stays valid HTML.
|
||||
function wrapSpan(cls) {
|
||||
if (!editor || currentIndex < 0) return;
|
||||
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);
|
||||
@ -436,7 +457,9 @@ function wrapSpan(cls) {
|
||||
}
|
||||
|
||||
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 selectedText = editor.getModel().getValueInRange(sel);
|
||||
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;"');
|
||||
}
|
||||
|
||||
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 ──────────────────────────────────────────────────────
|
||||
|
||||
@ -481,6 +508,8 @@ async function addChapter() {
|
||||
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();
|
||||
@ -529,6 +558,8 @@ async function deleteChapter() {
|
||||
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;
|
||||
@ -618,6 +649,7 @@ async function replaceInAllChapters() {
|
||||
let chaptersChanged = 0;
|
||||
|
||||
// Flush current editor content into pending before we start
|
||||
syncVisualToMonaco();
|
||||
const curCh = currentCh();
|
||||
if (curCh) pendingContent.set(curCh._id, editor.getValue());
|
||||
|
||||
@ -672,6 +704,7 @@ async function replaceInAllChapters() {
|
||||
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');
|
||||
}
|
||||
@ -690,6 +723,133 @@ async function replaceInAllChapters() {
|
||||
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 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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function setStatus(cls, text) {
|
||||
|
||||
@ -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-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>
|
||||
|
||||
<!-- 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">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="9"/>
|
||||
@ -77,6 +89,7 @@
|
||||
<div class="chapter-list" id="chapter-list"></div>
|
||||
</nav>
|
||||
<div class="editor-pane" id="editor-pane"></div>
|
||||
<div class="visual-pane" id="visual-pane" style="display:none"></div>
|
||||
</div>
|
||||
|
||||
<!-- Find & Replace modal -->
|
||||
@ -112,6 +125,7 @@
|
||||
};
|
||||
</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/editor.js"></script>
|
||||
</body>
|
||||
|
||||
@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
|
||||
from changelog import CHANGELOG
|
||||
|
||||
BUILD = 0
|
||||
BUILD = 1
|
||||
|
||||
|
||||
def _release_version() -> str:
|
||||
|
||||
@ -1,5 +1,15 @@
|
||||
# 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
|
||||
|
||||
### Changed
|
||||
|
||||
Loading…
Reference in New Issue
Block a user