- React + TipTap editor with formatting toolbar (bold, italic, underline, strikethrough, alignment, highlight, scene breaks) - Custom image node view with resize and alignment controls; server-side WebP conversion via sharp - Express + SQLite backend with JWT auth and admin user management - Export to PDF, EPUB, and ODT - Five themes (Midnight, Gothic Night, Enchanted Forest, Aged Manuscript, Neon Noir); Lora body font for readability - Writing streak, daily word goal, milestones, and Ollama writing prompts - Docker Compose setup for self-hosted deployment behind NPMplus Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
81 lines
2.7 KiB
JavaScript
81 lines
2.7 KiB
JavaScript
function esc(str) {
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
}
|
||
|
||
export function nodeToHtml(node) {
|
||
if (!node) return ''
|
||
const ch = () => (node.content || []).map(nodeToHtml).join('')
|
||
|
||
switch (node.type) {
|
||
case 'doc': return ch()
|
||
case 'paragraph': return `<p>${ch() || ' '}</p>\n`
|
||
case 'heading': {
|
||
const l = node.attrs?.level || 1
|
||
return `<h${l}>${ch()}</h${l}>\n`
|
||
}
|
||
case 'text': {
|
||
let t = esc(node.text || '')
|
||
for (const m of (node.marks || [])) {
|
||
if (m.type === 'bold') t = `<strong>${t}</strong>`
|
||
if (m.type === 'italic') t = `<em>${t}</em>`
|
||
if (m.type === 'underline') t = `<u>${t}</u>`
|
||
}
|
||
return t
|
||
}
|
||
case 'image': {
|
||
const { src = '', alt = '', width = '100%', align = 'center' } = node.attrs || {}
|
||
const ml = align === 'left' ? '0' : 'auto'
|
||
const mr = align === 'right' ? '0' : 'auto'
|
||
return `<img src="${esc(src)}" alt="${esc(alt)}" style="width:${esc(width)};display:block;margin-left:${ml};margin-right:${mr}"/>\n`
|
||
}
|
||
case 'bulletList': return `<ul>\n${ch()}</ul>\n`
|
||
case 'orderedList': return `<ol>\n${ch()}</ol>\n`
|
||
case 'listItem': return `<li>${ch()}</li>\n`
|
||
case 'blockquote': return `<blockquote>\n${ch()}</blockquote>\n`
|
||
case 'hardBreak': return '<br/>'
|
||
default: return ch()
|
||
}
|
||
}
|
||
|
||
export function extractChapters(doc) {
|
||
const chapters = []
|
||
for (const node of (doc?.content || [])) {
|
||
if (node.type === 'heading' && node.attrs?.level === 1) {
|
||
chapters.push((node.content || []).map(n => n.text || '').join('') || 'Chapter')
|
||
}
|
||
}
|
||
return chapters
|
||
}
|
||
|
||
// Splits the document into chapter sections at each H1 boundary
|
||
export function splitByChapters(doc, fallbackTitle = 'Story') {
|
||
const nodes = doc?.content || []
|
||
if (!nodes.length) return [{ title: fallbackTitle, html: '' }]
|
||
|
||
const groups = []
|
||
let current = { title: null, nodes: [] }
|
||
|
||
for (const node of nodes) {
|
||
if (node.type === 'heading' && node.attrs?.level === 1) {
|
||
if (current.nodes.length > 0 || current.title !== null) {
|
||
groups.push({ title: current.title || fallbackTitle, html: current.nodes.map(nodeToHtml).join('') })
|
||
}
|
||
current = {
|
||
title: (node.content || []).map(n => n.text || '').join('') || 'Chapter',
|
||
nodes: [node],
|
||
}
|
||
} else {
|
||
current.nodes.push(node)
|
||
}
|
||
}
|
||
if (current.nodes.length > 0 || current.title !== null) {
|
||
groups.push({ title: current.title || fallbackTitle, html: current.nodes.map(nodeToHtml).join('') })
|
||
}
|
||
|
||
return groups.length ? groups : [{ title: fallbackTitle, html: nodes.map(nodeToHtml).join('') }]
|
||
}
|