Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd34fd603c | |||
| 5d240988fb | |||
| ac640e4c16 | |||
| f93703bcc3 | |||
| 697c893a2f | |||
| 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');
|
||||
53
containers/novela/editor-src/diagnose.mjs
Normal file
53
containers/novela/editor-src/diagnose.mjs
Normal file
@ -0,0 +1,53 @@
|
||||
// Fetch every chapter of a book from the running app and report which ones are
|
||||
// visual-safe, and for the unsafe ones, the first canonical divergence.
|
||||
// node diagnose.mjs <encoded-filename>
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
const BASE = process.env.NOVELA_BASE || 'http://192.168.100.142:9099';
|
||||
const enc = process.argv[2];
|
||||
if (!enc) { console.error('usage: node diagnose.mjs <encoded-filename>'); process.exit(2); }
|
||||
|
||||
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;
|
||||
|
||||
const list = await (await fetch(`${BASE}/library/chapters/${enc}`)).json();
|
||||
console.log(`${list.length} chapters\n`);
|
||||
|
||||
function firstDiff(a, b) {
|
||||
// tokenise canonical strings on tag/text boundaries for a readable diff
|
||||
const split = (s) => s.match(/<[^>]+>|#[^<]*/g) || [];
|
||||
const ta = split(a), tb = split(b);
|
||||
for (let i = 0; i < Math.max(ta.length, tb.length); i++) {
|
||||
if (ta[i] !== tb[i]) {
|
||||
return { i, orig: ta.slice(i, i + 4).join(''), got: tb.slice(i, i + 4).join('') };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let unsafe = 0;
|
||||
const reasons = {};
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const data = await (await fetch(`${BASE}/api/edit/chapter/${i}/${enc}`)).json();
|
||||
const d = V.roundtripDebug(data.content);
|
||||
if (d.safe) { console.log(` ch${i} SAFE "${data.title}"`); continue; }
|
||||
unsafe++;
|
||||
const diff = firstDiff(d.before, d.after);
|
||||
console.log(` ch${i} UNSAFE "${data.title}"`);
|
||||
if (diff) {
|
||||
console.log(` orig: ${diff.orig.slice(0, 100)}`);
|
||||
console.log(` got: ${diff.got.slice(0, 100)}`);
|
||||
const key = (diff.orig.match(/<[^ >]+/) || ['?'])[0];
|
||||
reasons[key] = (reasons[key] || 0) + 1;
|
||||
}
|
||||
}
|
||||
console.log(`\n${unsafe}/${list.length} unsafe. First-divergence tag histogram:`, reasons);
|
||||
178
containers/novela/editor-src/extensions.js
Normal file
178
containers/novela/editor-src/extensions.js
Normal file
@ -0,0 +1,178 @@
|
||||
// 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) };
|
||||
},
|
||||
});
|
||||
|
||||
// Block variants: the HTML toolbar wraps a whole block selection as
|
||||
// <div class="subheading">…</div> / <div class="chat">…</div> (vs the inline
|
||||
// <span> marks above). Both occur in real content, so both must round-trip.
|
||||
export const SubheadingBlock = Node.create({
|
||||
name: 'subheadingBlock',
|
||||
group: 'block',
|
||||
content: 'block+',
|
||||
defining: true,
|
||||
parseHTML() { return [{ tag: 'div.subheading' }]; },
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { class: 'subheading' }), 0];
|
||||
},
|
||||
});
|
||||
|
||||
export const ChatBlock = Node.create({
|
||||
name: 'chatBlock',
|
||||
group: 'block',
|
||||
content: 'block+',
|
||||
defining: true,
|
||||
parseHTML() { return [{ tag: 'div.chat' }]; },
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { class: 'chat' }), 0];
|
||||
},
|
||||
});
|
||||
|
||||
// Preserve arbitrary class attributes on paragraphs/headings (e.g. the generated
|
||||
// Book Info page uses <p class="author">). Preserving rather than dropping keeps
|
||||
// such chapters visual-safe.
|
||||
export const ClassPreserve = Extension.create({
|
||||
name: 'classPreserve',
|
||||
addGlobalAttributes() {
|
||||
return [{
|
||||
types: ['paragraph', 'heading'],
|
||||
attributes: {
|
||||
class: {
|
||||
default: null,
|
||||
parseHTML: (el) => el.getAttribute('class') || null,
|
||||
renderHTML: (attrs) => (attrs.class ? { class: attrs.class } : {}),
|
||||
},
|
||||
},
|
||||
}];
|
||||
},
|
||||
});
|
||||
|
||||
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;' } }),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Block indent: the HTML toolbar wraps a block selection as
|
||||
// <div style="padding-left: 40px;">…</div> (vs the inline <p> style below).
|
||||
export const IndentBlock = Node.create({
|
||||
name: 'indentBlock',
|
||||
group: 'block',
|
||||
content: 'block+',
|
||||
defining: true,
|
||||
parseHTML() {
|
||||
return [{
|
||||
tag: 'div',
|
||||
getAttrs: (el) => /padding-left\s*:\s*40px/i.test(el.getAttribute('style') || '') ? {} : false,
|
||||
}];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { style: 'padding-left: 40px;' }), 0];
|
||||
},
|
||||
});
|
||||
|
||||
// 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
220
containers/novela/editor-src/index.js
Normal file
220
containers/novela/editor-src/index.js
Normal file
@ -0,0 +1,220 @@
|
||||
// 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 Link from '@tiptap/extension-link';
|
||||
import { Subheading, Chat, Comment, SceneBreak, Indent, IndentBlock,
|
||||
SubheadingBlock, ChatBlock, ClassPreserve } from './extensions.js';
|
||||
|
||||
function extensions() {
|
||||
return [
|
||||
StarterKit.configure({
|
||||
// Parse/preserve every heading level for fidelity; the toolbar still only
|
||||
// offers H2/H3 for authoring.
|
||||
heading: { levels: [1, 2, 3, 4, 5, 6] },
|
||||
}),
|
||||
Underline,
|
||||
Superscript,
|
||||
Subscript,
|
||||
// Don't inject rel/target — keep links byte-identical so they round-trip.
|
||||
Link.configure({ openOnClick: false, autolink: false, HTMLAttributes: { rel: null, target: null } }),
|
||||
Indent,
|
||||
IndentBlock,
|
||||
ClassPreserve,
|
||||
Subheading,
|
||||
Chat,
|
||||
Comment,
|
||||
SubheadingBlock,
|
||||
ChatBlock,
|
||||
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' };
|
||||
// Inline formatting elements are treated as MARKS rather than nested tags: a text
|
||||
// run carries an unordered SET of marks. This mirrors how the editor stores inline
|
||||
// formatting, so benign rewrites it performs read as equal — dropping empty marks,
|
||||
// merging adjacent identical marks, and re-ordering nested marks
|
||||
// (<em><strong>x</strong></em> ⇄ <strong><em>x</em></strong>).
|
||||
const MARK_TAGS = new Set(['strong', 'em', 'u', 's', 'sup', 'sub', 'code', 'a']);
|
||||
|
||||
function tagName(el) { return TAG_EQUIV[el.tagName.toLowerCase()] || el.tagName.toLowerCase(); }
|
||||
|
||||
function attrSig(el) {
|
||||
return [...el.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().join(' ');
|
||||
}
|
||||
|
||||
function markSig(el) {
|
||||
const a = attrSig(el);
|
||||
return tagName(el) + (a ? ' ' + a : '');
|
||||
}
|
||||
|
||||
// An element is an inline mark if it's a known formatting tag, or a <span> that
|
||||
// carries attributes (a bare <span> is transparently unwrapped by the editor and
|
||||
// is treated as carrying no mark). Everything else is structural.
|
||||
function isMark(el) {
|
||||
const t = tagName(el);
|
||||
if (MARK_TAGS.has(t)) return true;
|
||||
if (t === 'span') return el.attributes.length > 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
const isWsText = (c) => c.nodeType === 3 && c.nodeValue.replace(/\s+/g, '').length === 0;
|
||||
// A child is inline if it's text, a mark, a <span>, or <br>; everything else is a
|
||||
// structural block element.
|
||||
const isInline = (c) => c.nodeType === 3 ||
|
||||
(c.nodeType === 1 && (isMark(c) || tagName(c) === 'span' || tagName(c) === 'br'));
|
||||
|
||||
function canonical(html) {
|
||||
if (typeof DOMParser === 'undefined') return html;
|
||||
const doc = new DOMParser().parseFromString(html || '', 'text/html');
|
||||
const out = [];
|
||||
const pushRun = (marks, text) => {
|
||||
const key = '{' + marks.slice().sort().join('|') + '}';
|
||||
const prev = out.length ? out[out.length - 1] : '';
|
||||
if (prev.startsWith(key + '#')) out[out.length - 1] = prev + text; // merge same-mark runs
|
||||
else out.push(key + '#' + text);
|
||||
};
|
||||
const walkChild = (child, marks) => {
|
||||
if (child.nodeType === 3) { // text
|
||||
const t = child.nodeValue.replace(/\s+/g, ' ').trim();
|
||||
if (t !== '') pushRun(marks, t);
|
||||
return;
|
||||
}
|
||||
if (child.nodeType !== 1) return; // ignore comments etc.
|
||||
if (isMark(child)) {
|
||||
walk(child, marks.concat(markSig(child)));
|
||||
} else if (tagName(child) === 'span') {
|
||||
walk(child, marks); // bare span: transparent
|
||||
} else if (tagName(child) === 'br') {
|
||||
pushRun(marks, '⏎'); // hard break marker
|
||||
} else {
|
||||
const attrs = attrSig(child);
|
||||
out.push('<' + tagName(child) + (attrs ? ' ' + attrs : '') + '>');
|
||||
walk(child, []); // structural element resets inline context
|
||||
out.push('</' + tagName(child) + '>');
|
||||
}
|
||||
};
|
||||
// Plain children walk (inline context, e.g. inside a textblock).
|
||||
const walk = (node, marks) => { node.childNodes.forEach((c) => walkChild(c, marks)); };
|
||||
// Body is a block container: the editor wraps any loose run of inline content in
|
||||
// a paragraph. Mirror that by emitting a synthetic <p> around such runs, so this
|
||||
// lossless rewrite compares equal.
|
||||
const walkBlockContainer = (node) => {
|
||||
const kids = [...node.childNodes];
|
||||
let i = 0;
|
||||
while (i < kids.length) {
|
||||
const c = kids[i];
|
||||
if (c.nodeType === 1 && !isInline(c)) { walkChild(c, []); i++; continue; }
|
||||
if (isWsText(c)) { i++; continue; } // ignore whitespace between blocks
|
||||
out.push('<p>'); // synthetic wrapper for a loose inline run
|
||||
while (i < kids.length && (isInline(kids[i]) || isWsText(kids[i]))) {
|
||||
if (!isWsText(kids[i])) walkChild(kids[i], []);
|
||||
i++;
|
||||
}
|
||||
out.push('</p>');
|
||||
}
|
||||
};
|
||||
// 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.
|
||||
const body = doc.body;
|
||||
doc.documentElement.childNodes.forEach((c) => {
|
||||
if (c.nodeType === 1 && c === body) {
|
||||
out.push('<body>');
|
||||
walkBlockContainer(body);
|
||||
out.push('</body>');
|
||||
} else {
|
||||
walkChild(c, []);
|
||||
}
|
||||
});
|
||||
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' };
|
||||
}
|
||||
|
||||
// Diagnostic helper: returns the canonical forms so callers can see exactly what
|
||||
// the editor would drop/alter. Not used in production UI.
|
||||
export function roundtripDebug(html) {
|
||||
const r = roundtripSafe(html);
|
||||
const host = document.createElement('div');
|
||||
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) { /* ignore */ }
|
||||
host.remove();
|
||||
return { safe: r.safe, before: canonical(html), after: canonical(after), afterHTML: after };
|
||||
}
|
||||
21
containers/novela/editor-src/package.json
Normal file
21
containers/novela/editor-src/package.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"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-link": "^2.27.2",
|
||||
"@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>
|
||||
62
containers/novela/editor-src/test.mjs
Normal file
62
containers/novela/editor-src/test.mjs
Normal file
@ -0,0 +1,62 @@
|
||||
// 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: '' },
|
||||
// Guardrails: genuine loss must still be blocked despite the broadened schema.
|
||||
{ name: 'standalone image dropped', safe: false, html: '<p>Look:</p><img src="/pic.jpg" alt="a"/>' },
|
||||
{ name: 'unknown div class dropped', safe: false, html: '<div class="pullquote"><p>quote</p></div>' },
|
||||
{ name: 'font color dropped', safe: false, html: '<p><font color="red">red</font></p>' },
|
||||
{ name: 'table dropped', safe: false, html: '<table><tr><td>a</td><td>b</td></tr></table>' },
|
||||
// Newly-supported conventions must be allowed.
|
||||
{ name: 'block subheading div', safe: true, html: '<div class="subheading"><p>- Matt -</p></div>' },
|
||||
{ name: 'div indent', safe: true, html: '<div style="padding-left: 40px;"><p>indented block</p></div>' },
|
||||
{ name: 'h1 + p.class + hr', safe: true, html: '<h1>Title</h1><p class="author">by X</p><hr/><p>body</p>' },
|
||||
{ name: 'nested bold+italic any order', safe: true, html: '<p><em><strong>x</strong></em> <strong><em>y</em></strong></p>' },
|
||||
{ name: 'link preserved', safe: true, html: '<p>see <a href="https://example.com">here</a></p>' },
|
||||
];
|
||||
|
||||
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);
|
||||
97
containers/novela/static/editor-bundle.js
Normal file
97
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 {
|
||||
width: 100%; margin: 0; padding: 2.5rem 3rem 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,127 @@ 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 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) {
|
||||
|
||||
@ -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-sup" onclick="vCmd('toggleSuperscript')" title="Superscript">x²</button>
|
||||
<button class="vbtn" id="vb-sub" onclick="vCmd('toggleSubscript')" title="Subscript">x₂</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 = 5
|
||||
|
||||
|
||||
def _release_version() -> str:
|
||||
|
||||
@ -1,5 +1,38 @@
|
||||
# Develop Changelog
|
||||
|
||||
## 2026-06-13 — Visual editor: drop H2/H3 buttons, add superscript/subscript
|
||||
|
||||
### Changed
|
||||
- Replaced the H2 and H3 heading buttons in the visual (WYSIWYG) toolbar with **superscript (x²)** and **subscript (x₂)** buttons. The heading buttons weren't part of the novela-ng editor and aren't used; superscript/subscript were missing. Heading levels remain in the schema so existing headings in other books still round-trip — only the authoring buttons changed. `templates/editor.html` (toolbar buttons), `static/editor.js` (`updateVisualButtons` active-state, removed unused `vHeading`). No bundle rebuild (sup/sub extensions were already bundled).
|
||||
|
||||
## 2026-06-12 — Visual editor: full-width editing column
|
||||
|
||||
### Changed
|
||||
- Made the Visual (WYSIWYG) editing column fill the full editor pane width: dropped the max-width cap entirely (now `width: 100%`, margin 0, with 3rem horizontal padding) — 70rem was still too narrow. `static/editor.css` (`.visual-pane .ProseMirror`). CSS only; no bundle rebuild.
|
||||
|
||||
## 2026-06-12 — Visual editor: wider editing column
|
||||
|
||||
### Changed
|
||||
- Widened the Visual (WYSIWYG) editing column from the reader's 42rem reading width to 70rem with more horizontal padding — the reading width was comfortable to read but too narrow to edit in. `static/editor.css` (`.visual-pane .ProseMirror`). CSS only; no bundle rebuild.
|
||||
|
||||
## 2026-06-12 — Visual editor: broaden schema & fidelity check against real library content
|
||||
|
||||
### Changed
|
||||
- Tested the round-trip safety gate against every chapter of the actual DB library and broadened the visual editor so Visual mode is available for the overwhelming majority of real content instead of being refused on common, lossless markup. Across the sampled DB books (~185 chapters) Visual mode now opens for 100% of chapters; only genuinely lossy or malformed markup is still blocked.
|
||||
- **Schema broadened** to cover conventions that real chapters actually use (all now round-trip losslessly): the **block** variants of subheading and chat (`<div class="subheading">…</div>`, `<div class="chat">…</div>` — previously only the inline `<span>` marks were modelled), **block indent** (`<div style="padding-left: 40px;">…</div>`), all **heading levels** h1–h6 (was h2/h3 only; the Book Info page uses `<h1>`, and some books use `<h4>` separators), plain **`<hr>`** rules (re-enabled alongside the `<center><img>` scene break), **links** (`<a href>`, with rel/target injection disabled so links stay byte-identical), and preservation of arbitrary **`class`** attributes on paragraphs/headings (e.g. the generated `<p class="author">`).
|
||||
- **Round-trip comparison made tolerant of the editor's lossless normalisations**, so they no longer read as "unsafe": inline formatting is now compared as an unordered **mark set** per text run (so `<em><strong>x</strong></em>` and `<strong><em>x</em></strong>` are equal, empty marks like `<strong></strong>` are ignored, and adjacent identical marks merge), and loose inline content sitting directly under `<body>` is compared as if wrapped in a paragraph (matching how the editor wraps it). Genuine loss is still blocked — verified by guardrail tests that unknown tags (`<table>`, `<font color>`), standalone `<img>`, unknown `<div class>` wrappers, and whole-xhtml EPUB documents all remain "unsafe".
|
||||
- Files: `containers/novela/editor-src/extensions.js` (added `SubheadingBlock`, `ChatBlock`, `IndentBlock`, `ClassPreserve`); `containers/novela/editor-src/index.js` (extension list, heading levels, Link, mark-set canonicaliser with body-level loose-inline wrapping, `roundtripDebug` diagnostic export); `containers/novela/editor-src/package.json` (+`@tiptap/extension-link`); rebuilt `static/editor-bundle.js`; new dev harnesses `editor-src/{diagnose.mjs,test-commands.mjs}` and expanded `editor-src/test.mjs`. No backend or `editor.js`/`editor.html`/`editor.css` changes in this round.
|
||||
|
||||
## 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