first commit
This commit is contained in:
465
static/css/markdown-editor.css
Normal file
465
static/css/markdown-editor.css
Normal file
@@ -0,0 +1,465 @@
|
||||
/* ============================================
|
||||
Markdown Editor Styles
|
||||
UPDATED: Removed slash command styles
|
||||
UPDATED: Added image-btn style for new-block-line
|
||||
============================================= */
|
||||
|
||||
/* Chapter Marker */
|
||||
.chapter-marker {
|
||||
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
||||
border: 2px solid #f59e0b;
|
||||
border-radius: var(--border-radius);
|
||||
padding: 16px 20px;
|
||||
margin: 24px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chapter-marker-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chapter-label {
|
||||
font-weight: 700;
|
||||
color: #92400e;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.875rem;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.chapter-number-input {
|
||||
width: 80px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
border: 2px solid #f59e0b;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.chapter-voice-select {
|
||||
min-width: 180px;
|
||||
border: 2px solid #f59e0b;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.chapter-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Markdown Block */
|
||||
.md-block {
|
||||
position: relative;
|
||||
margin: 8px 0;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.md-block:hover {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.md-block.editing {
|
||||
background: #eff6ff;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
/* Block Content */
|
||||
.md-block-content {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.8;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.md-block-content h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.md-block-content h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.md-block-content h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.md-block-content p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.md-block-content blockquote {
|
||||
border-left: 4px solid var(--primary-color);
|
||||
padding-left: 16px;
|
||||
margin: 0;
|
||||
font-style: italic;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.md-block-content ul,
|
||||
.md-block-content ol {
|
||||
margin: 0;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.md-block-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--border-radius-sm);
|
||||
margin: 8px auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.md-block-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.md-block-content th,
|
||||
.md-block-content td {
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.md-block-content th {
|
||||
background: var(--bg-tertiary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Block Edit Mode */
|
||||
.md-block-edit {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.md-block.editing .md-block-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.md-block.editing .md-block-edit {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.md-block-textarea {
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.md-block-textarea:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Block Toolbar */
|
||||
.md-block-toolbar {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 0;
|
||||
background: white;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: 4px;
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.md-block.editing .md-block-toolbar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.toolbar-btn.active {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toolbar-divider {
|
||||
width: 1px;
|
||||
background: var(--border-color);
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
/* Block Type Menu */
|
||||
.block-type-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
background: white;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 8px;
|
||||
min-width: 200px;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.block-type-menu.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.block-type-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.block-type-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.block-type-item i {
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-secondary);
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.block-type-item span {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.block-type-item small {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Empty Block Placeholder */
|
||||
.md-block-placeholder {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
New Block Line
|
||||
============================================= */
|
||||
|
||||
.new-block-line {
|
||||
height: 24px;
|
||||
position: relative;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.new-block-line:hover::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
height: 2px;
|
||||
background: var(--primary-color);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.new-block-line:hover .add-line-buttons {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.add-line-buttons {
|
||||
display: none;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
gap: 8px;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.add-line-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.add-line-btn:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Image add button - teal */
|
||||
.add-line-btn.image-btn {
|
||||
background: #06b6d4;
|
||||
}
|
||||
|
||||
.add-line-btn.image-btn:hover {
|
||||
background: #0891b2;
|
||||
}
|
||||
|
||||
/* Chapter add button - amber */
|
||||
.add-line-btn.chapter-btn {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.add-line-btn.chapter-btn:hover {
|
||||
background: #d97706;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Image Block - Centered
|
||||
============================================= */
|
||||
|
||||
.image-block {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--bg-tertiary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.image-block:hover {
|
||||
border-color: var(--primary-color);
|
||||
background: rgba(79, 70, 229, 0.05);
|
||||
}
|
||||
|
||||
.image-block img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--border-radius-sm);
|
||||
margin: 0 auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.image-upload-placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.image-upload-placeholder i {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Audio Indicator
|
||||
============================================= */
|
||||
|
||||
.audio-indicator {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.audio-indicator.has-audio {
|
||||
background: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.audio-indicator.no-audio {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Block Actions Indicator (Edit + Delete)
|
||||
============================================= */
|
||||
|
||||
.block-actions-indicator {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 40px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.md-block:hover .block-actions-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.action-indicator-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.action-indicator-btn.edit-block-btn:hover {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-indicator-btn.delete-block-btn:hover {
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Hide the old block-edit-indicator since we replaced it */
|
||||
.block-edit-indicator {
|
||||
display: none !important;
|
||||
}
|
||||
1268
static/css/style.css
Normal file
1268
static/css/style.css
Normal file
File diff suppressed because it is too large
Load Diff
899
static/js/app.js
Normal file
899
static/js/app.js
Normal file
@@ -0,0 +1,899 @@
|
||||
/**
|
||||
* Audiobook Maker Pro v3.1 - Main Application
|
||||
* UPDATED: Dynamic header help button, floating guide panel, no hint bar
|
||||
* UPDATED: Hide guide panel by default when Interactive Reader tab is active
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// Global State
|
||||
// ============================================
|
||||
|
||||
let currentProject = {
|
||||
id: null,
|
||||
name: 'My Audiobook',
|
||||
chapters: []
|
||||
};
|
||||
|
||||
let voices = [];
|
||||
let archiveModal = null;
|
||||
let ttsEditModal = null;
|
||||
let currentWorkflowStage = 'upload';
|
||||
|
||||
// ============================================
|
||||
// Initialization
|
||||
// ============================================
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('🎧 Audiobook Maker Pro v3.1 initializing...');
|
||||
|
||||
archiveModal = new bootstrap.Modal(document.getElementById('archiveModal'));
|
||||
ttsEditModal = new bootstrap.Modal(document.getElementById('ttsEditModal'));
|
||||
|
||||
loadVoices();
|
||||
initPdfHandler();
|
||||
initMarkdownEditor();
|
||||
setupEventListeners();
|
||||
|
||||
// Show welcome overlay for first-time users
|
||||
initWelcomeOverlay();
|
||||
|
||||
// Initialize workflow progress
|
||||
updateWorkflowProgress('upload');
|
||||
|
||||
// Initialize floating guide panel
|
||||
initFloatingGuidePanel();
|
||||
|
||||
// Load current user info
|
||||
loadCurrentUser();
|
||||
|
||||
// Ensure reader UI is hidden on startup
|
||||
if (typeof hideReaderUI === 'function') {
|
||||
hideReaderUI();
|
||||
}
|
||||
|
||||
console.log('✅ Application initialized');
|
||||
});
|
||||
|
||||
function setupEventListeners() {
|
||||
document.getElementById('projectName').addEventListener('change', function() {
|
||||
currentProject.name = this.value;
|
||||
});
|
||||
|
||||
document.getElementById('reader-tab').addEventListener('shown.bs.tab', function() {
|
||||
renderInteractiveReader();
|
||||
if (typeof showReaderUI === 'function') {
|
||||
showReaderUI();
|
||||
}
|
||||
// Hide the guide panel when entering the reader
|
||||
hideGuidePanelForReader();
|
||||
});
|
||||
|
||||
document.getElementById('editor-tab').addEventListener('shown.bs.tab', function() {
|
||||
if (typeof hideReaderUI === 'function') {
|
||||
hideReaderUI();
|
||||
}
|
||||
// Restore the guide panel when returning to the editor
|
||||
restoreGuidePanelForEditor();
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Welcome Overlay
|
||||
// ============================================
|
||||
|
||||
function initWelcomeOverlay() {
|
||||
const dontShow = localStorage.getItem('audiobookMakerHideWelcome');
|
||||
if (dontShow === 'true') {
|
||||
document.getElementById('welcomeOverlay').style.display = 'none';
|
||||
} else {
|
||||
document.getElementById('welcomeOverlay').style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
function dismissWelcome() {
|
||||
const dontShowCheckbox = document.getElementById('welcomeDontShow');
|
||||
if (dontShowCheckbox && dontShowCheckbox.checked) {
|
||||
localStorage.setItem('audiobookMakerHideWelcome', 'true');
|
||||
}
|
||||
document.getElementById('welcomeOverlay').style.display = 'none';
|
||||
}
|
||||
|
||||
function showWelcome() {
|
||||
document.getElementById('welcomeOverlay').style.display = 'flex';
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Dynamic Header Help Button
|
||||
// ============================================
|
||||
|
||||
function handleHeaderHelp() {
|
||||
if (currentWorkflowStage === 'upload') {
|
||||
showWelcome();
|
||||
} else {
|
||||
showGuidePanel();
|
||||
}
|
||||
}
|
||||
|
||||
function updateHeaderHelpButton(stage) {
|
||||
const label = document.getElementById('headerHelpLabel');
|
||||
const btn = document.getElementById('headerHelpBtn');
|
||||
|
||||
if (!label || !btn) return;
|
||||
|
||||
if (stage === 'upload') {
|
||||
label.textContent = 'Quick Start';
|
||||
btn.title = 'Show quick start guide';
|
||||
} else {
|
||||
label.textContent = 'Quick Guide';
|
||||
btn.title = 'Show editor quick guide';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Floating Guide Panel
|
||||
// ============================================
|
||||
|
||||
let guidePanelDragState = {
|
||||
isDragging: false,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
offsetX: 0,
|
||||
offsetY: 0
|
||||
};
|
||||
|
||||
function initFloatingGuidePanel() {
|
||||
const panel = document.getElementById('floatingGuidePanel');
|
||||
const header = document.getElementById('guidePanelHeader');
|
||||
const toggle = document.getElementById('floatingGuideToggle');
|
||||
|
||||
if (!panel || !header) return;
|
||||
|
||||
// Check if user previously hid the panel permanently
|
||||
const hideGuide = localStorage.getItem('audiobookMakerHideGuide');
|
||||
if (hideGuide === 'true') {
|
||||
panel.classList.remove('visible');
|
||||
if (toggle) toggle.classList.add('visible');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user previously collapsed
|
||||
const collapsed = localStorage.getItem('audiobookMakerGuideCollapsed');
|
||||
if (collapsed === 'true') {
|
||||
panel.classList.add('collapsed');
|
||||
const icon = document.getElementById('guideCollapseIcon');
|
||||
if (icon) {
|
||||
icon.classList.remove('bi-chevron-up');
|
||||
icon.classList.add('bi-chevron-down');
|
||||
}
|
||||
}
|
||||
|
||||
// Restore saved position
|
||||
const savedPos = localStorage.getItem('audiobookMakerGuidePos');
|
||||
if (savedPos) {
|
||||
try {
|
||||
const pos = JSON.parse(savedPos);
|
||||
const maxX = window.innerWidth - 100;
|
||||
const maxY = window.innerHeight - 50;
|
||||
if (pos.x >= 0 && pos.x <= maxX && pos.y >= 0 && pos.y <= maxY) {
|
||||
panel.style.right = 'auto';
|
||||
panel.style.left = pos.x + 'px';
|
||||
panel.style.top = pos.y + 'px';
|
||||
}
|
||||
} catch(e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// Setup drag events (mouse)
|
||||
header.addEventListener('mousedown', onGuideDragStart);
|
||||
document.addEventListener('mousemove', onGuideDragMove);
|
||||
document.addEventListener('mouseup', onGuideDragEnd);
|
||||
|
||||
// Touch support
|
||||
header.addEventListener('touchstart', onGuideTouchStart, { passive: false });
|
||||
document.addEventListener('touchmove', onGuideTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', onGuideTouchEnd);
|
||||
}
|
||||
|
||||
function showGuidePanelOnEditor() {
|
||||
const panel = document.getElementById('floatingGuidePanel');
|
||||
const toggle = document.getElementById('floatingGuideToggle');
|
||||
const hideGuide = localStorage.getItem('audiobookMakerHideGuide');
|
||||
|
||||
if (hideGuide === 'true') {
|
||||
if (toggle) toggle.classList.add('visible');
|
||||
return;
|
||||
}
|
||||
|
||||
if (panel) panel.classList.add('visible');
|
||||
if (toggle) toggle.classList.remove('visible');
|
||||
}
|
||||
|
||||
function showGuidePanel() {
|
||||
const panel = document.getElementById('floatingGuidePanel');
|
||||
const toggle = document.getElementById('floatingGuideToggle');
|
||||
|
||||
if (panel) panel.classList.add('visible');
|
||||
if (toggle) toggle.classList.remove('visible');
|
||||
|
||||
localStorage.removeItem('audiobookMakerHideGuide');
|
||||
}
|
||||
|
||||
function hideGuidePanel() {
|
||||
const panel = document.getElementById('floatingGuidePanel');
|
||||
const toggle = document.getElementById('floatingGuideToggle');
|
||||
|
||||
if (panel) panel.classList.remove('visible');
|
||||
if (toggle) toggle.classList.add('visible');
|
||||
}
|
||||
|
||||
function toggleGuideCollapse() {
|
||||
const panel = document.getElementById('floatingGuidePanel');
|
||||
const icon = document.getElementById('guideCollapseIcon');
|
||||
|
||||
if (!panel) return;
|
||||
|
||||
const isCollapsed = panel.classList.toggle('collapsed');
|
||||
|
||||
if (icon) {
|
||||
if (isCollapsed) {
|
||||
icon.classList.remove('bi-chevron-up');
|
||||
icon.classList.add('bi-chevron-down');
|
||||
} else {
|
||||
icon.classList.remove('bi-chevron-down');
|
||||
icon.classList.add('bi-chevron-up');
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem('audiobookMakerGuideCollapsed', isCollapsed ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function handleGuideDontShow() {
|
||||
const checkbox = document.getElementById('guidePanelDontShow');
|
||||
if (checkbox && checkbox.checked) {
|
||||
localStorage.setItem('audiobookMakerHideGuide', 'true');
|
||||
hideGuidePanel();
|
||||
} else {
|
||||
localStorage.removeItem('audiobookMakerHideGuide');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Guide Panel: Reader Tab Visibility
|
||||
// ============================================
|
||||
|
||||
let guidePanelHiddenByReader = false;
|
||||
|
||||
function hideGuidePanelForReader() {
|
||||
const panel = document.getElementById('floatingGuidePanel');
|
||||
const toggle = document.getElementById('floatingGuideToggle');
|
||||
|
||||
// If the panel is currently visible, hide it and remember we did so
|
||||
if (panel && panel.classList.contains('visible')) {
|
||||
panel.classList.remove('visible');
|
||||
guidePanelHiddenByReader = true;
|
||||
} else {
|
||||
guidePanelHiddenByReader = false;
|
||||
}
|
||||
|
||||
// Always show the toggle button so the user CAN show it manually if they want
|
||||
if (toggle) toggle.classList.add('visible');
|
||||
}
|
||||
|
||||
function restoreGuidePanelForEditor() {
|
||||
// Only restore if we were the ones who hid it
|
||||
if (guidePanelHiddenByReader) {
|
||||
const panel = document.getElementById('floatingGuidePanel');
|
||||
const toggle = document.getElementById('floatingGuideToggle');
|
||||
const hideGuide = localStorage.getItem('audiobookMakerHideGuide');
|
||||
|
||||
if (hideGuide !== 'true' && panel) {
|
||||
panel.classList.add('visible');
|
||||
if (toggle) toggle.classList.remove('visible');
|
||||
}
|
||||
guidePanelHiddenByReader = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Drag Logic (Mouse) ---
|
||||
|
||||
function onGuideDragStart(e) {
|
||||
if (e.target.closest('.guide-panel-btn') || e.target.closest('button')) return;
|
||||
|
||||
const panel = document.getElementById('floatingGuidePanel');
|
||||
if (!panel) return;
|
||||
|
||||
guidePanelDragState.isDragging = true;
|
||||
|
||||
const rect = panel.getBoundingClientRect();
|
||||
guidePanelDragState.offsetX = e.clientX - rect.left;
|
||||
guidePanelDragState.offsetY = e.clientY - rect.top;
|
||||
|
||||
panel.style.transition = 'none';
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onGuideDragMove(e) {
|
||||
if (!guidePanelDragState.isDragging) return;
|
||||
|
||||
const panel = document.getElementById('floatingGuidePanel');
|
||||
if (!panel) return;
|
||||
|
||||
let newX = e.clientX - guidePanelDragState.offsetX;
|
||||
let newY = e.clientY - guidePanelDragState.offsetY;
|
||||
|
||||
const panelWidth = panel.offsetWidth;
|
||||
const panelHeight = panel.offsetHeight;
|
||||
newX = Math.max(0, Math.min(newX, window.innerWidth - panelWidth));
|
||||
newY = Math.max(0, Math.min(newY, window.innerHeight - panelHeight));
|
||||
|
||||
panel.style.right = 'auto';
|
||||
panel.style.left = newX + 'px';
|
||||
panel.style.top = newY + 'px';
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onGuideDragEnd(e) {
|
||||
if (!guidePanelDragState.isDragging) return;
|
||||
|
||||
guidePanelDragState.isDragging = false;
|
||||
|
||||
const panel = document.getElementById('floatingGuidePanel');
|
||||
if (panel) {
|
||||
panel.style.transition = '';
|
||||
localStorage.setItem('audiobookMakerGuidePos', JSON.stringify({
|
||||
x: parseInt(panel.style.left) || 0,
|
||||
y: parseInt(panel.style.top) || 0
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Drag Logic (Touch) ---
|
||||
|
||||
function onGuideTouchStart(e) {
|
||||
if (e.target.closest('.guide-panel-btn') || e.target.closest('button')) return;
|
||||
|
||||
const panel = document.getElementById('floatingGuidePanel');
|
||||
if (!panel) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
guidePanelDragState.isDragging = true;
|
||||
|
||||
const rect = panel.getBoundingClientRect();
|
||||
guidePanelDragState.offsetX = touch.clientX - rect.left;
|
||||
guidePanelDragState.offsetY = touch.clientY - rect.top;
|
||||
|
||||
panel.style.transition = 'none';
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onGuideTouchMove(e) {
|
||||
if (!guidePanelDragState.isDragging) return;
|
||||
|
||||
const panel = document.getElementById('floatingGuidePanel');
|
||||
if (!panel) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
let newX = touch.clientX - guidePanelDragState.offsetX;
|
||||
let newY = touch.clientY - guidePanelDragState.offsetY;
|
||||
|
||||
const panelWidth = panel.offsetWidth;
|
||||
const panelHeight = panel.offsetHeight;
|
||||
newX = Math.max(0, Math.min(newX, window.innerWidth - panelWidth));
|
||||
newY = Math.max(0, Math.min(newY, window.innerHeight - panelHeight));
|
||||
|
||||
panel.style.right = 'auto';
|
||||
panel.style.left = newX + 'px';
|
||||
panel.style.top = newY + 'px';
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onGuideTouchEnd(e) {
|
||||
if (!guidePanelDragState.isDragging) return;
|
||||
|
||||
guidePanelDragState.isDragging = false;
|
||||
|
||||
const panel = document.getElementById('floatingGuidePanel');
|
||||
if (panel) {
|
||||
panel.style.transition = '';
|
||||
localStorage.setItem('audiobookMakerGuidePos', JSON.stringify({
|
||||
x: parseInt(panel.style.left) || 0,
|
||||
y: parseInt(panel.style.top) || 0
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Workflow Progress Bar
|
||||
// ============================================
|
||||
|
||||
function updateWorkflowProgress(stage) {
|
||||
const step1 = document.getElementById('wpStep1');
|
||||
const step2 = document.getElementById('wpStep2');
|
||||
const step3 = document.getElementById('wpStep3');
|
||||
const conn1 = document.getElementById('wpConn1');
|
||||
const conn2 = document.getElementById('wpConn2');
|
||||
|
||||
if (!step1) return;
|
||||
|
||||
// Track current stage for help button
|
||||
currentWorkflowStage = stage;
|
||||
updateHeaderHelpButton(stage);
|
||||
|
||||
// Reset all
|
||||
[step1, step2, step3].forEach(s => {
|
||||
s.classList.remove('completed', 'active');
|
||||
});
|
||||
[conn1, conn2].forEach(c => {
|
||||
c.classList.remove('active');
|
||||
});
|
||||
|
||||
switch (stage) {
|
||||
case 'upload':
|
||||
step1.classList.add('active');
|
||||
break;
|
||||
case 'edit':
|
||||
step1.classList.add('completed');
|
||||
conn1.classList.add('active');
|
||||
step2.classList.add('active');
|
||||
// Show the floating guide when entering editor
|
||||
showGuidePanelOnEditor();
|
||||
break;
|
||||
case 'audio-ready':
|
||||
step1.classList.add('completed');
|
||||
conn1.classList.add('active');
|
||||
step2.classList.add('completed');
|
||||
conn2.classList.add('active');
|
||||
step3.classList.add('active');
|
||||
// Show badge on reader tab
|
||||
const badge = document.getElementById('readerTabBadge');
|
||||
if (badge) badge.style.display = 'inline';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Helper: Switch to Editor Tab
|
||||
// ============================================
|
||||
|
||||
function switchToEditorTab() {
|
||||
const editorTab = document.getElementById('editor-tab');
|
||||
if (editorTab) {
|
||||
const tab = new bootstrap.Tab(editorTab);
|
||||
tab.show();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Helper: Start from scratch
|
||||
// ============================================
|
||||
|
||||
function startFromScratch() {
|
||||
document.getElementById('uploadSection').style.display = 'none';
|
||||
document.getElementById('editorSection').style.display = 'block';
|
||||
updateWorkflowProgress('edit');
|
||||
|
||||
// Click the editor to trigger first chapter creation
|
||||
const editor = document.getElementById('markdownEditor');
|
||||
if (editor && editorBlocks.length === 0) {
|
||||
addChapterMarker(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Loading Overlay
|
||||
// ============================================
|
||||
|
||||
function showLoader(text = 'Processing...', subtext = 'Please wait') {
|
||||
const overlay = document.getElementById('loadingOverlay');
|
||||
document.getElementById('loadingText').textContent = text;
|
||||
document.getElementById('loadingSubtext').textContent = subtext;
|
||||
overlay.classList.add('active');
|
||||
}
|
||||
|
||||
function hideLoader() {
|
||||
document.getElementById('loadingOverlay').classList.remove('active');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Voice Management
|
||||
// ============================================
|
||||
|
||||
async function loadVoices() {
|
||||
try {
|
||||
const response = await fetch('/api/voices');
|
||||
const data = await response.json();
|
||||
voices = data.voices || [];
|
||||
console.log(`📢 Loaded ${voices.length} voices`);
|
||||
} catch (error) {
|
||||
console.error('Failed to load voices:', error);
|
||||
voices = [
|
||||
{ id: 'af_heart', name: 'Heart (US Female)' },
|
||||
{ id: 'am_adam', name: 'Adam (US Male)' }
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function getVoiceOptions(selectedVoice = 'af_heart') {
|
||||
return voices.map(v =>
|
||||
`<option value="${v.id}" ${v.id === selectedVoice ? 'selected' : ''}>${v.name}</option>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Project Management
|
||||
// ============================================
|
||||
|
||||
async function saveProject() {
|
||||
const projectName = document.getElementById('projectName').value.trim();
|
||||
|
||||
if (!projectName) {
|
||||
alert('Please enter a project name');
|
||||
return;
|
||||
}
|
||||
|
||||
currentProject.name = projectName;
|
||||
const chapters = collectEditorContent();
|
||||
|
||||
if (chapters.length === 0) {
|
||||
alert('No content to save. Add some chapters and blocks first.');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoader('Saving Project...', 'Please wait');
|
||||
|
||||
try {
|
||||
let projectId = currentProject.id;
|
||||
|
||||
if (!projectId) {
|
||||
const createResponse = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: projectName })
|
||||
});
|
||||
|
||||
const createData = await createResponse.json();
|
||||
|
||||
if (createData.error) {
|
||||
const listResponse = await fetch('/api/projects');
|
||||
const listData = await listResponse.json();
|
||||
const existing = listData.projects.find(p => p.name === projectName);
|
||||
|
||||
if (existing) {
|
||||
projectId = existing.id;
|
||||
} else {
|
||||
throw new Error(createData.error);
|
||||
}
|
||||
} else {
|
||||
projectId = createData.project_id;
|
||||
}
|
||||
|
||||
currentProject.id = projectId;
|
||||
}
|
||||
|
||||
const saveResponse = await fetch(`/api/projects/${projectId}/save`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chapters })
|
||||
});
|
||||
|
||||
const saveData = await saveResponse.json();
|
||||
|
||||
if (saveData.error) {
|
||||
throw new Error(saveData.error);
|
||||
}
|
||||
|
||||
hideLoader();
|
||||
showNotification('Project saved successfully!', 'success');
|
||||
|
||||
} catch (error) {
|
||||
hideLoader();
|
||||
console.error('Save error:', error);
|
||||
alert('Failed to save project: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportProject() {
|
||||
if (!currentProject.id) {
|
||||
await saveProject();
|
||||
if (!currentProject.id) return;
|
||||
}
|
||||
|
||||
showLoader('Exporting...', 'Creating ZIP file');
|
||||
|
||||
try {
|
||||
window.location.href = `/api/export/${currentProject.id}`;
|
||||
setTimeout(() => { hideLoader(); }, 2000);
|
||||
} catch (error) {
|
||||
hideLoader();
|
||||
alert('Export failed: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function openProjectArchive() {
|
||||
showLoader('Loading projects...');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/projects');
|
||||
const data = await response.json();
|
||||
|
||||
const container = document.getElementById('projectList');
|
||||
|
||||
if (data.projects.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-folder2-open" style="font-size: 3rem;"></i>
|
||||
<p class="mt-3">No saved projects yet</p>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
container.innerHTML = data.projects.map(project => `
|
||||
<div class="project-item">
|
||||
<div class="project-info">
|
||||
<h6>${escapeHtml(project.name)}</h6>
|
||||
<div class="project-meta">
|
||||
${project.chapter_count} chapters • ${project.block_count} blocks •
|
||||
Updated ${formatDate(project.updated_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="project-actions">
|
||||
<button class="btn btn-sm btn-primary" onclick="loadProject(${project.id})">
|
||||
<i class="bi bi-folder2-open"></i> Load
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteProject(${project.id})">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
hideLoader();
|
||||
archiveModal.show();
|
||||
|
||||
} catch (error) {
|
||||
hideLoader();
|
||||
alert('Failed to load projects: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProject(projectId) {
|
||||
showLoader('Loading project...');
|
||||
archiveModal.hide();
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
currentProject = { id: data.id, name: data.name, chapters: data.chapters };
|
||||
document.getElementById('projectName').value = data.name;
|
||||
|
||||
// Hide upload, show editor
|
||||
document.getElementById('uploadSection').style.display = 'none';
|
||||
document.getElementById('editorSection').style.display = 'block';
|
||||
|
||||
renderProjectInEditor(data);
|
||||
|
||||
// Check if project has audio and update workflow
|
||||
let hasAudio = false;
|
||||
for (const ch of data.chapters) {
|
||||
for (const bl of ch.blocks) {
|
||||
if (bl.audio_data && bl.block_type !== 'image') {
|
||||
hasAudio = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasAudio) break;
|
||||
}
|
||||
updateWorkflowProgress(hasAudio ? 'audio-ready' : 'edit');
|
||||
|
||||
hideLoader();
|
||||
showNotification('Project loaded successfully!', 'success');
|
||||
|
||||
} catch (error) {
|
||||
hideLoader();
|
||||
alert('Failed to load project: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProject(projectId) {
|
||||
if (!confirm('Are you sure you want to delete this project? This action cannot be undone.')) return;
|
||||
|
||||
showLoader('Deleting...');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}`, { method: 'DELETE' });
|
||||
const data = await response.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
await openProjectArchive();
|
||||
showNotification('Project deleted', 'success');
|
||||
|
||||
} catch (error) {
|
||||
hideLoader();
|
||||
alert('Failed to delete project: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// TTS Text Editing
|
||||
// ============================================
|
||||
|
||||
function openTtsEditor(blockId, currentContent) {
|
||||
const plainText = stripMarkdown(currentContent);
|
||||
document.getElementById('ttsTextInput').value = plainText;
|
||||
document.getElementById('ttsBlockId').value = blockId;
|
||||
ttsEditModal.show();
|
||||
}
|
||||
|
||||
function saveTtsText() {
|
||||
const blockId = document.getElementById('ttsBlockId').value;
|
||||
const ttsText = document.getElementById('ttsTextInput').value;
|
||||
|
||||
const block = document.querySelector(`[data-block-id="${blockId}"]`);
|
||||
if (block) {
|
||||
block.dataset.ttsText = ttsText;
|
||||
}
|
||||
|
||||
ttsEditModal.hide();
|
||||
showNotification('TTS text saved', 'success');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Utility Functions
|
||||
// ============================================
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function stripMarkdown(text) {
|
||||
if (!text) return '';
|
||||
|
||||
let result = text;
|
||||
result = result.replace(/^#{1,6}\s*/gm, '');
|
||||
result = result.replace(/\*\*(.+?)\*\*/g, '$1');
|
||||
result = result.replace(/\*(.+?)\*/g, '$1');
|
||||
result = result.replace(/__(.+?)__/g, '$1');
|
||||
result = result.replace(/_(.+?)_/g, '$1');
|
||||
result = result.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
|
||||
result = result.replace(/!\[([^\]]*)\]\([^)]+\)/g, '');
|
||||
result = result.replace(/^>\s*/gm, '');
|
||||
result = result.replace(/^\s*[-*+]\s+/gm, '');
|
||||
result = result.replace(/^\s*\d+\.\s+/gm, '');
|
||||
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
function showNotification(message, type = 'info') {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `alert alert-${type === 'success' ? 'success' : type === 'error' ? 'danger' : 'info'} notification-toast`;
|
||||
notification.innerHTML = `
|
||||
<i class="bi bi-${type === 'success' ? 'check-circle' : type === 'error' ? 'exclamation-circle' : 'info-circle'} me-2"></i>
|
||||
${message}
|
||||
`;
|
||||
notification.style.cssText = `
|
||||
position: fixed; top: 20px; right: 20px; z-index: 9999;
|
||||
min-width: 250px; animation: slideIn 0.3s ease;
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.style.animation = 'slideOut 0.3s ease';
|
||||
setTimeout(() => notification.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Authentication
|
||||
// ============================================
|
||||
|
||||
let changePasswordModal = null;
|
||||
|
||||
async function loadCurrentUser() {
|
||||
try {
|
||||
const response = await fetch('/api/auth/me');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.user) {
|
||||
const usernameEl = document.getElementById('headerUsername');
|
||||
if (usernameEl) usernameEl.textContent = data.user.username;
|
||||
|
||||
// Show admin menu item if admin
|
||||
if (data.user.role === 'admin') {
|
||||
const adminItem = document.getElementById('adminMenuItem');
|
||||
const adminDivider = document.getElementById('adminDivider');
|
||||
if (adminItem) adminItem.style.display = 'block';
|
||||
if (adminDivider) adminDivider.style.display = 'block';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load user info:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
} catch(e) { /* ignore */ }
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
function openChangePassword() {
|
||||
if (!changePasswordModal) {
|
||||
changePasswordModal = new bootstrap.Modal(document.getElementById('changePasswordModal'));
|
||||
}
|
||||
document.getElementById('currentPassword').value = '';
|
||||
document.getElementById('newPassword').value = '';
|
||||
document.getElementById('confirmPassword').value = '';
|
||||
document.getElementById('changePasswordError').style.display = 'none';
|
||||
changePasswordModal.show();
|
||||
}
|
||||
|
||||
async function submitChangePassword() {
|
||||
const currentPassword = document.getElementById('currentPassword').value;
|
||||
const newPassword = document.getElementById('newPassword').value;
|
||||
const confirmPassword = document.getElementById('confirmPassword').value;
|
||||
const errorDiv = document.getElementById('changePasswordError');
|
||||
|
||||
errorDiv.style.display = 'none';
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
errorDiv.textContent = 'All fields are required';
|
||||
errorDiv.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
errorDiv.textContent = 'New passwords do not match';
|
||||
errorDiv.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 4) {
|
||||
errorDiv.textContent = 'New password must be at least 4 characters';
|
||||
errorDiv.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
errorDiv.textContent = data.error;
|
||||
errorDiv.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
changePasswordModal.hide();
|
||||
showNotification('Password changed successfully!', 'success');
|
||||
} catch(e) {
|
||||
errorDiv.textContent = 'Network error. Please try again.';
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const notifStyle = document.createElement('style');
|
||||
notifStyle.textContent = `
|
||||
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||
@keyframes slideOut { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } }
|
||||
`;
|
||||
document.head.appendChild(notifStyle);
|
||||
215
static/js/generation.js
Normal file
215
static/js/generation.js
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Audio Generation Module
|
||||
* UPDATED: Updates workflow progress after successful generation
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// Single Block Generation
|
||||
// ============================================
|
||||
|
||||
async function generateBlockAudio(blockId) {
|
||||
const block = document.getElementById(blockId);
|
||||
if (!block) {
|
||||
console.error('Block not found:', blockId);
|
||||
return;
|
||||
}
|
||||
|
||||
const blockType = block.dataset.blockType || 'paragraph';
|
||||
|
||||
if (blockType === 'image') {
|
||||
alert('Cannot generate audio for image blocks.');
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = block.querySelector('.md-block-textarea');
|
||||
const content = textarea ? textarea.value : '';
|
||||
|
||||
if (!content.trim()) {
|
||||
alert('No text content to generate audio for.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (content.trim().startsWith(' !== -1) {
|
||||
alert('Cannot generate audio for image blocks.');
|
||||
return;
|
||||
}
|
||||
|
||||
const ttsText = (block.dataset.ttsText && block.dataset.ttsText.trim()) ? block.dataset.ttsText : content;
|
||||
|
||||
let voice = 'af_heart';
|
||||
let prevElement = block.previousElementSibling;
|
||||
while (prevElement) {
|
||||
if (prevElement.classList.contains('chapter-marker')) {
|
||||
voice = prevElement.dataset.voice || 'af_heart';
|
||||
break;
|
||||
}
|
||||
prevElement = prevElement.previousElementSibling;
|
||||
}
|
||||
|
||||
showLoader('Generating Audio...', 'Creating speech and timestamps');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: ttsText,
|
||||
voice: voice,
|
||||
block_id: null
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
const blockData = editorBlocks.find(b => b.id === blockId);
|
||||
if (blockData) {
|
||||
blockData.audio_data = data.audio_data;
|
||||
blockData.audio_format = data.audio_format;
|
||||
blockData.transcription = data.transcription;
|
||||
}
|
||||
|
||||
const indicator = block.querySelector('.audio-indicator');
|
||||
if (indicator) {
|
||||
indicator.classList.remove('no-audio');
|
||||
indicator.classList.add('has-audio');
|
||||
indicator.title = 'Audio generated';
|
||||
}
|
||||
|
||||
hideLoader();
|
||||
showNotification('Audio generated successfully!', 'success');
|
||||
|
||||
// Update workflow to show audio is ready
|
||||
updateWorkflowProgress('audio-ready');
|
||||
|
||||
} catch (error) {
|
||||
hideLoader();
|
||||
console.error('Generation error:', error);
|
||||
alert('Failed to generate audio: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Chapter Generation
|
||||
// ============================================
|
||||
|
||||
async function generateChapterAudio(chapterId) {
|
||||
const chapterMarker = document.getElementById(chapterId);
|
||||
if (!chapterMarker) {
|
||||
console.error('Chapter marker not found:', chapterId);
|
||||
return;
|
||||
}
|
||||
|
||||
const voice = chapterMarker.dataset.voice || 'af_heart';
|
||||
|
||||
const blocksToGenerate = [];
|
||||
let nextElement = chapterMarker.nextElementSibling;
|
||||
|
||||
while (nextElement && !nextElement.classList.contains('chapter-marker')) {
|
||||
if (nextElement.classList.contains('md-block')) {
|
||||
const blockType = nextElement.dataset.blockType || 'paragraph';
|
||||
|
||||
if (blockType === 'image') {
|
||||
nextElement = nextElement.nextElementSibling;
|
||||
continue;
|
||||
}
|
||||
|
||||
const textarea = nextElement.querySelector('.md-block-textarea');
|
||||
const content = textarea ? textarea.value : '';
|
||||
|
||||
if (!content.trim()) {
|
||||
nextElement = nextElement.nextElementSibling;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (content.trim().startsWith(' !== -1) {
|
||||
nextElement = nextElement.nextElementSibling;
|
||||
continue;
|
||||
}
|
||||
|
||||
const ttsText = (nextElement.dataset.ttsText && nextElement.dataset.ttsText.trim())
|
||||
? nextElement.dataset.ttsText
|
||||
: content;
|
||||
|
||||
blocksToGenerate.push({
|
||||
id: nextElement.id,
|
||||
text: ttsText,
|
||||
element: nextElement
|
||||
});
|
||||
}
|
||||
nextElement = nextElement.nextElementSibling;
|
||||
}
|
||||
|
||||
if (blocksToGenerate.length === 0) {
|
||||
alert('No text blocks found in this chapter to generate audio for.');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoader(`Generating Chapter Audio...`, `Processing ${blocksToGenerate.length} blocks`);
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (let i = 0; i < blocksToGenerate.length; i++) {
|
||||
const blockInfo = blocksToGenerate[i];
|
||||
|
||||
document.getElementById('loadingSubtext').textContent =
|
||||
`Block ${i + 1} of ${blocksToGenerate.length}`;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: blockInfo.text,
|
||||
voice: voice,
|
||||
block_id: null
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
console.error(`Block ${blockInfo.id} error:`, data.error);
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const blockData = editorBlocks.find(b => b.id === blockInfo.id);
|
||||
if (blockData) {
|
||||
blockData.audio_data = data.audio_data;
|
||||
blockData.audio_format = data.audio_format;
|
||||
blockData.transcription = data.transcription;
|
||||
}
|
||||
|
||||
const indicator = blockInfo.element.querySelector('.audio-indicator');
|
||||
if (indicator) {
|
||||
indicator.classList.remove('no-audio');
|
||||
indicator.classList.add('has-audio');
|
||||
indicator.title = 'Audio generated';
|
||||
}
|
||||
|
||||
successCount++;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Block ${blockInfo.id} error:`, error);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
hideLoader();
|
||||
|
||||
if (errorCount > 0) {
|
||||
showNotification(`Generated ${successCount} blocks, ${errorCount} failed`, 'warning');
|
||||
} else {
|
||||
showNotification(`Generated audio for ${successCount} blocks!`, 'success');
|
||||
}
|
||||
|
||||
// Update workflow to show audio is ready
|
||||
if (successCount > 0) {
|
||||
updateWorkflowProgress('audio-ready');
|
||||
}
|
||||
}
|
||||
798
static/js/interactive-reader.js
Normal file
798
static/js/interactive-reader.js
Normal file
@@ -0,0 +1,798 @@
|
||||
/**
|
||||
* Interactive Reader Module - Rewritten
|
||||
* FIXED: Images from manual upload now render correctly using base64 data
|
||||
* Features:
|
||||
* - Single Start button, becomes play/pause after first click
|
||||
* - Auto-advance to next block audio
|
||||
* - Click any word to play from that point
|
||||
* - Word + Sentence highlighting with smart sync
|
||||
* - Left sidebar chapter navigation
|
||||
* - Images rendered from base64 data (both processed and uploaded)
|
||||
* - Pause/Resume works correctly
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// Reader State
|
||||
// ============================================
|
||||
|
||||
let readerInstances = [];
|
||||
let currentReaderInstance = null;
|
||||
let currentReaderIndex = -1;
|
||||
let readerStarted = false;
|
||||
let readerUICreated = false;
|
||||
|
||||
// ============================================
|
||||
// Render Reader
|
||||
// ============================================
|
||||
|
||||
function renderInteractiveReader() {
|
||||
const container = document.getElementById('readerContainer');
|
||||
const chapters = collectEditorContent();
|
||||
|
||||
let hasAudio = false;
|
||||
const chaptersWithAudio = [];
|
||||
|
||||
for (const chapter of chapters) {
|
||||
const chapterBlocks = [];
|
||||
for (const block of chapter.blocks) {
|
||||
// Match editor block by ID lookup
|
||||
const blockData = findEditorBlockForContent(block);
|
||||
|
||||
const isImageBlock = block.block_type === 'image' ||
|
||||
(block.content && block.content.trim().startsWith(');
|
||||
|
||||
chapterBlocks.push({
|
||||
...block,
|
||||
_editorData: blockData || null,
|
||||
_isImage: isImageBlock
|
||||
});
|
||||
|
||||
if (!isImageBlock && blockData && blockData.audio_data) {
|
||||
hasAudio = true;
|
||||
}
|
||||
}
|
||||
chaptersWithAudio.push({
|
||||
...chapter,
|
||||
blocks: chapterBlocks
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasAudio) {
|
||||
container.innerHTML = `
|
||||
<div class="reader-empty-state">
|
||||
<i class="bi bi-book"></i>
|
||||
<p>Generate audio to view the interactive reader</p>
|
||||
<p class="text-muted">Go to the Editor tab and click "Generate" on blocks or chapters</p>
|
||||
</div>
|
||||
`;
|
||||
removeReaderUI();
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
readerInstances = [];
|
||||
let globalBlockIndex = 0;
|
||||
|
||||
for (const chapter of chaptersWithAudio) {
|
||||
html += `<div class="reader-chapter" id="reader-chapter-${chapter.chapter_number}">`;
|
||||
html += `<h2 class="reader-chapter-title">Chapter ${chapter.chapter_number}</h2>`;
|
||||
|
||||
for (const block of chapter.blocks) {
|
||||
const blockData = block._editorData;
|
||||
const isImageBlock = block._isImage;
|
||||
|
||||
const hasBlockAudio = !isImageBlock && blockData && blockData.audio_data;
|
||||
const blockId = blockData ? blockData.id : `reader_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
|
||||
|
||||
html += `<div class="reader-block" data-block-id="${blockId}" data-reader-index="${globalBlockIndex}" data-has-audio="${!!hasBlockAudio}">`;
|
||||
|
||||
if (isImageBlock) {
|
||||
const imageHtml = buildImageHtml(block, blockData);
|
||||
html += `<div class="reader-content reader-image-block">${imageHtml}</div>`;
|
||||
} else {
|
||||
// Render before-position images from the block's images array
|
||||
const blockImages = getBlockImages(block, blockData);
|
||||
for (const img of blockImages) {
|
||||
if (img.position === 'before' && img.data) {
|
||||
html += `<div class="reader-image-block"><img src="data:image/${img.format || 'png'};base64,${img.data}" alt="${img.alt_text || 'Image'}"></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
html += `<div class="reader-content" id="reader-content-${globalBlockIndex}"></div>`;
|
||||
|
||||
// Render after-position images
|
||||
for (const img of blockImages) {
|
||||
if (img.position === 'after' && img.data) {
|
||||
html += `<div class="reader-image-block"><img src="data:image/${img.format || 'png'};base64,${img.data}" alt="${img.alt_text || 'Image'}"></div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
|
||||
readerInstances.push({
|
||||
index: globalBlockIndex,
|
||||
blockId: blockId,
|
||||
blockData: blockData,
|
||||
content: block.content,
|
||||
hasAudio: !!hasBlockAudio,
|
||||
isImage: isImageBlock,
|
||||
chapterNumber: chapter.chapter_number,
|
||||
wordSpans: [],
|
||||
wordMap: [],
|
||||
sentenceData: [],
|
||||
audio: null,
|
||||
transcription: (!isImageBlock && blockData) ? (blockData.transcription || []) : [],
|
||||
animFrameId: null,
|
||||
lastWordSpan: null,
|
||||
lastSentenceSpans: []
|
||||
});
|
||||
|
||||
globalBlockIndex++;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
for (const inst of readerInstances) {
|
||||
if (inst.isImage || !inst.content) continue;
|
||||
|
||||
const contentEl = document.getElementById(`reader-content-${inst.index}`);
|
||||
if (!contentEl) continue;
|
||||
|
||||
renderWordsIntoContainer(contentEl, inst);
|
||||
|
||||
if (inst.hasAudio && inst.transcription.length > 0) {
|
||||
runReaderSmartSync(inst);
|
||||
}
|
||||
}
|
||||
|
||||
addReaderStyles();
|
||||
setupReaderUI(chaptersWithAudio);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Image Resolution Helpers
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Find the editorBlocks entry that corresponds to a collected block.
|
||||
* Uses multiple strategies: ID match, then content match.
|
||||
*/
|
||||
function findEditorBlockForContent(block) {
|
||||
// Strategy 1: Try matching via DOM element ID
|
||||
for (const eb of editorBlocks) {
|
||||
const el = document.getElementById(eb.id);
|
||||
if (el) {
|
||||
const textarea = el.querySelector('.md-block-textarea');
|
||||
if (textarea && textarea.value === block.content) {
|
||||
return eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Match by content string directly
|
||||
for (const eb of editorBlocks) {
|
||||
if (eb.content === block.content) {
|
||||
return eb;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all images for a block from every available source.
|
||||
*/
|
||||
function getBlockImages(block, blockData) {
|
||||
// Priority 1: block.images from collectEditorContent (most reliable)
|
||||
if (block.images && block.images.length > 0) {
|
||||
const valid = block.images.filter(img => img.data && img.data.length > 0);
|
||||
if (valid.length > 0) return valid;
|
||||
}
|
||||
|
||||
// Priority 2: editorBlocks data
|
||||
if (blockData && blockData.images && blockData.images.length > 0) {
|
||||
const valid = blockData.images.filter(img => img.data && img.data.length > 0);
|
||||
if (valid.length > 0) return valid;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the HTML for an image block in the reader.
|
||||
* Resolves base64 data from multiple sources.
|
||||
*/
|
||||
function buildImageHtml(block, blockData) {
|
||||
// Source 1: block.images array (from collectEditorContent)
|
||||
if (block.images && block.images.length > 0) {
|
||||
let html = '';
|
||||
for (const img of block.images) {
|
||||
if (img.data && img.data.length > 0) {
|
||||
html += `<img src="data:image/${img.format || 'png'};base64,${img.data}" alt="${img.alt_text || 'Image'}">`;
|
||||
}
|
||||
}
|
||||
if (html) return html;
|
||||
}
|
||||
|
||||
// Source 2: editorBlocks images array
|
||||
if (blockData && blockData.images && blockData.images.length > 0) {
|
||||
let html = '';
|
||||
for (const img of blockData.images) {
|
||||
if (img.data && img.data.length > 0) {
|
||||
html += `<img src="data:image/${img.format || 'png'};base64,${img.data}" alt="${img.alt_text || 'Image'}">`;
|
||||
}
|
||||
}
|
||||
if (html) return html;
|
||||
}
|
||||
|
||||
// Source 3: Extract data URI from markdown content itself
|
||||
if (block.content) {
|
||||
const dataUriMatch = block.content.match(/!\[([^\]]*)\]\((data:image\/[^)]+)\)/);
|
||||
if (dataUriMatch) {
|
||||
return `<img src="${dataUriMatch[2]}" alt="${dataUriMatch[1] || 'Image'}">`;
|
||||
}
|
||||
}
|
||||
|
||||
// Source 4: Grab the rendered image directly from the editor DOM
|
||||
if (blockData && blockData.id) {
|
||||
const editorBlock = document.getElementById(blockData.id);
|
||||
if (editorBlock) {
|
||||
const editorImg = editorBlock.querySelector('.image-block img, .md-block-content img');
|
||||
if (editorImg && editorImg.src && editorImg.src.startsWith('data:image')) {
|
||||
return `<img src="${editorImg.src}" alt="Image">`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Source 5: Scan ALL editorBlocks for matching content to find images
|
||||
if (block.content) {
|
||||
for (const eb of editorBlocks) {
|
||||
if (eb.content === block.content && eb.images && eb.images.length > 0) {
|
||||
let html = '';
|
||||
for (const img of eb.images) {
|
||||
if (img.data && img.data.length > 0) {
|
||||
html += `<img src="data:image/${img.format || 'png'};base64,${img.data}" alt="${img.alt_text || 'Image'}">`;
|
||||
}
|
||||
}
|
||||
if (html) return html;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: placeholder
|
||||
return `<div class="reader-image-placeholder">
|
||||
<i class="bi bi-image" style="font-size:2rem;color:#94a3b8;"></i>
|
||||
<p style="color:#94a3b8;margin-top:8px;">Image not available</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Word Rendering & Sync
|
||||
// ============================================
|
||||
|
||||
function renderWordsIntoContainer(container, inst) {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = marked.parse(inst.content, { breaks: true, gfm: true });
|
||||
|
||||
inst.wordSpans = [];
|
||||
|
||||
function processNode(node) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const words = node.textContent.split(/(\s+)/);
|
||||
const fragment = document.createDocumentFragment();
|
||||
words.forEach(part => {
|
||||
if (part.trim().length > 0) {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'reader-word';
|
||||
span.textContent = part;
|
||||
span.dataset.readerIndex = inst.index;
|
||||
span.dataset.wordIdx = inst.wordSpans.length;
|
||||
inst.wordSpans.push(span);
|
||||
fragment.appendChild(span);
|
||||
} else {
|
||||
fragment.appendChild(document.createTextNode(part));
|
||||
}
|
||||
});
|
||||
node.parentNode.replaceChild(fragment, node);
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
Array.from(node.childNodes).forEach(processNode);
|
||||
}
|
||||
}
|
||||
|
||||
processNode(div);
|
||||
while (div.firstChild) container.appendChild(div.firstChild);
|
||||
}
|
||||
|
||||
function runReaderSmartSync(inst) {
|
||||
const { wordSpans, transcription } = inst;
|
||||
inst.wordMap = new Array(wordSpans.length).fill(undefined);
|
||||
let aiIdx = 0;
|
||||
|
||||
wordSpans.forEach((span, i) => {
|
||||
const textWord = span.textContent.toLowerCase().replace(/[^\w]/g, '');
|
||||
for (let off = 0; off < 5; off++) {
|
||||
if (aiIdx + off >= transcription.length) break;
|
||||
const aiWord = transcription[aiIdx + off].word.toLowerCase().replace(/[^\w]/g, '');
|
||||
if (textWord === aiWord) {
|
||||
inst.wordMap[i] = aiIdx + off;
|
||||
aiIdx += off + 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
inst.sentenceData = [];
|
||||
let buffer = [];
|
||||
let startIdx = 0;
|
||||
|
||||
wordSpans.forEach((span, i) => {
|
||||
buffer.push(span);
|
||||
if (/[.!?]["'\u201D\u2019]?$/.test(span.textContent.trim())) {
|
||||
let startT = 0, endT = 0;
|
||||
for (let k = startIdx; k <= i; k++) {
|
||||
if (inst.wordMap[k] !== undefined) {
|
||||
startT = transcription[inst.wordMap[k]].start;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (let k = i; k >= startIdx; k--) {
|
||||
if (inst.wordMap[k] !== undefined) {
|
||||
endT = transcription[inst.wordMap[k]].end;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (endT > startT) {
|
||||
inst.sentenceData.push({ spans: [...buffer], startTime: startT, endTime: endT });
|
||||
}
|
||||
buffer = [];
|
||||
startIdx = i + 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (buffer.length > 0) {
|
||||
let startT = 0, endT = 0;
|
||||
for (let k = startIdx; k < wordSpans.length; k++) {
|
||||
if (inst.wordMap[k] !== undefined) {
|
||||
startT = transcription[inst.wordMap[k]].start;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (let k = wordSpans.length - 1; k >= startIdx; k--) {
|
||||
if (inst.wordMap[k] !== undefined) {
|
||||
endT = transcription[inst.wordMap[k]].end;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (endT > startT) {
|
||||
inst.sentenceData.push({ spans: [...buffer], startTime: startT, endTime: endT });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Reader UI
|
||||
// ============================================
|
||||
|
||||
function setupReaderUI(chaptersWithAudio) {
|
||||
removeReaderUI();
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.id = 'reader-floating-btn';
|
||||
btn.innerHTML = `
|
||||
<span id="reader-btn-text">Start</span>
|
||||
<svg id="reader-btn-play" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="display:none;width:24px;height:24px;"><path d="M8 5v14l11-7z"/></svg>
|
||||
<svg id="reader-btn-pause" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="display:none;width:24px;height:24px;"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
||||
`;
|
||||
document.body.appendChild(btn);
|
||||
btn.addEventListener('click', handleReaderFloatingClick);
|
||||
|
||||
const nav = document.createElement('nav');
|
||||
nav.id = 'reader-chapter-nav';
|
||||
let navHtml = '<ul>';
|
||||
const seenChapters = new Set();
|
||||
for (const ch of chaptersWithAudio) {
|
||||
if (!seenChapters.has(ch.chapter_number)) {
|
||||
seenChapters.add(ch.chapter_number);
|
||||
navHtml += `<li><a href="#reader-chapter-${ch.chapter_number}" data-chapter="${ch.chapter_number}">${ch.chapter_number}</a></li>`;
|
||||
}
|
||||
}
|
||||
navHtml += '</ul>';
|
||||
nav.innerHTML = navHtml;
|
||||
document.body.appendChild(nav);
|
||||
|
||||
const container = document.getElementById('readerContainer');
|
||||
container.addEventListener('click', handleReaderWordClick);
|
||||
|
||||
setupReaderNavObserver();
|
||||
|
||||
readerStarted = false;
|
||||
currentReaderInstance = null;
|
||||
currentReaderIndex = -1;
|
||||
readerUICreated = true;
|
||||
|
||||
positionReaderUI();
|
||||
window.addEventListener('resize', positionReaderUI);
|
||||
window.addEventListener('scroll', positionReaderUI);
|
||||
}
|
||||
|
||||
function positionReaderUI() {
|
||||
const readerContainer = document.getElementById('readerContainer');
|
||||
const btn = document.getElementById('reader-floating-btn');
|
||||
const nav = document.getElementById('reader-chapter-nav');
|
||||
|
||||
if (!readerContainer || !btn || !nav) return;
|
||||
|
||||
const containerRect = readerContainer.getBoundingClientRect();
|
||||
|
||||
btn.style.position = 'fixed';
|
||||
btn.style.top = '80px';
|
||||
const rightPos = window.innerWidth - (containerRect.right + 8);
|
||||
btn.style.right = Math.max(rightPos, 8) + 'px';
|
||||
btn.style.left = 'auto';
|
||||
|
||||
nav.style.position = 'fixed';
|
||||
nav.style.top = '50%';
|
||||
nav.style.transform = 'translateY(-50%)';
|
||||
const navWidth = nav.offsetWidth || 52;
|
||||
const leftPos = containerRect.left - navWidth - 8;
|
||||
nav.style.left = Math.max(leftPos, 8) + 'px';
|
||||
}
|
||||
|
||||
function removeReaderUI() {
|
||||
const oldBtn = document.getElementById('reader-floating-btn');
|
||||
if (oldBtn) oldBtn.remove();
|
||||
const oldNav = document.getElementById('reader-chapter-nav');
|
||||
if (oldNav) oldNav.remove();
|
||||
readerStarted = false;
|
||||
currentReaderInstance = null;
|
||||
currentReaderIndex = -1;
|
||||
readerUICreated = false;
|
||||
|
||||
window.removeEventListener('resize', positionReaderUI);
|
||||
window.removeEventListener('scroll', positionReaderUI);
|
||||
|
||||
for (const inst of readerInstances) {
|
||||
if (inst.audio) {
|
||||
inst.audio.pause();
|
||||
inst.audio = null;
|
||||
}
|
||||
cancelAnimationFrame(inst.animFrameId);
|
||||
}
|
||||
}
|
||||
|
||||
function showReaderUI() {
|
||||
const btn = document.getElementById('reader-floating-btn');
|
||||
const nav = document.getElementById('reader-chapter-nav');
|
||||
if (btn) btn.style.display = 'flex';
|
||||
if (nav) nav.style.display = 'block';
|
||||
positionReaderUI();
|
||||
}
|
||||
|
||||
function hideReaderUI() {
|
||||
const btn = document.getElementById('reader-floating-btn');
|
||||
const nav = document.getElementById('reader-chapter-nav');
|
||||
if (btn) btn.style.display = 'none';
|
||||
if (nav) nav.style.display = 'none';
|
||||
}
|
||||
|
||||
function setupReaderNavObserver() {
|
||||
const chapters = document.querySelectorAll('.reader-chapter');
|
||||
if (chapters.length === 0) return;
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const chNum = entry.target.id.replace('reader-chapter-', '');
|
||||
const navLinks = document.querySelectorAll('#reader-chapter-nav a');
|
||||
navLinks.forEach(l => l.classList.remove('active'));
|
||||
const activeLink = document.querySelector(`#reader-chapter-nav a[data-chapter="${chNum}"]`);
|
||||
if (activeLink) activeLink.classList.add('active');
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.3 });
|
||||
|
||||
chapters.forEach(ch => observer.observe(ch));
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Playback Logic
|
||||
// ============================================
|
||||
|
||||
function handleReaderFloatingClick() {
|
||||
if (!readerStarted) {
|
||||
readerStarted = true;
|
||||
updateReaderButton('playing');
|
||||
playReaderInstanceByIndex(findNextAudioIndex(-1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentReaderInstance && currentReaderInstance.audio) {
|
||||
if (currentReaderInstance.audio.paused) {
|
||||
currentReaderInstance.audio.play();
|
||||
updateReaderButton('playing');
|
||||
} else {
|
||||
currentReaderInstance.audio.pause();
|
||||
updateReaderButton('paused');
|
||||
}
|
||||
} else {
|
||||
playReaderInstanceByIndex(findNextAudioIndex(-1));
|
||||
updateReaderButton('playing');
|
||||
}
|
||||
}
|
||||
|
||||
function handleReaderWordClick(event) {
|
||||
const wordSpan = event.target.closest('.reader-word');
|
||||
if (!wordSpan) return;
|
||||
|
||||
const readerIdx = parseInt(wordSpan.dataset.readerIndex, 10);
|
||||
const wordIdx = parseInt(wordSpan.dataset.wordIdx, 10);
|
||||
const inst = readerInstances[readerIdx];
|
||||
|
||||
if (!inst || !inst.hasAudio) return;
|
||||
|
||||
const aiIdx = inst.wordMap[wordIdx];
|
||||
if (aiIdx === undefined) return;
|
||||
|
||||
const timestamp = inst.transcription[aiIdx].start;
|
||||
|
||||
if (currentReaderInstance && currentReaderInstance !== inst) {
|
||||
stopReaderInstance(currentReaderInstance);
|
||||
}
|
||||
|
||||
readerStarted = true;
|
||||
currentReaderIndex = readerIdx;
|
||||
currentReaderInstance = inst;
|
||||
|
||||
ensureAudioLoaded(inst);
|
||||
inst.audio.currentTime = timestamp;
|
||||
inst.audio.play();
|
||||
updateReaderButton('playing');
|
||||
startReaderHighlightLoop(inst);
|
||||
}
|
||||
|
||||
function findNextAudioIndex(afterIndex) {
|
||||
for (let i = afterIndex + 1; i < readerInstances.length; i++) {
|
||||
if (readerInstances[i].hasAudio) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function playReaderInstanceByIndex(index) {
|
||||
if (index < 0 || index >= readerInstances.length) {
|
||||
updateReaderButton('paused');
|
||||
currentReaderInstance = null;
|
||||
currentReaderIndex = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
const inst = readerInstances[index];
|
||||
if (!inst.hasAudio) {
|
||||
playReaderInstanceByIndex(findNextAudioIndex(index));
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentReaderInstance && currentReaderInstance !== inst) {
|
||||
stopReaderInstance(currentReaderInstance);
|
||||
}
|
||||
|
||||
currentReaderIndex = index;
|
||||
currentReaderInstance = inst;
|
||||
|
||||
ensureAudioLoaded(inst);
|
||||
inst.audio.currentTime = 0;
|
||||
inst.audio.play();
|
||||
updateReaderButton('playing');
|
||||
startReaderHighlightLoop(inst);
|
||||
|
||||
const blockEl = document.querySelector(`.reader-block[data-reader-index="${index}"]`);
|
||||
if (blockEl) {
|
||||
blockEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
function ensureAudioLoaded(inst) {
|
||||
if (!inst.audio) {
|
||||
const blockData = inst.blockData;
|
||||
if (!blockData || !blockData.audio_data) return;
|
||||
|
||||
const audioBlob = base64ToBlob(blockData.audio_data, `audio/${blockData.audio_format || 'mp3'}`);
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
inst.audio = new Audio(audioUrl);
|
||||
|
||||
inst.audio.addEventListener('ended', () => {
|
||||
stopReaderHighlightLoop(inst);
|
||||
clearReaderHighlights(inst);
|
||||
const nextIdx = findNextAudioIndex(inst.index);
|
||||
if (nextIdx >= 0) {
|
||||
playReaderInstanceByIndex(nextIdx);
|
||||
} else {
|
||||
updateReaderButton('paused');
|
||||
currentReaderInstance = null;
|
||||
currentReaderIndex = -1;
|
||||
}
|
||||
});
|
||||
|
||||
inst.audio.addEventListener('pause', () => {
|
||||
stopReaderHighlightLoop(inst);
|
||||
});
|
||||
|
||||
inst.audio.addEventListener('play', () => {
|
||||
startReaderHighlightLoop(inst);
|
||||
updateReaderButton('playing');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function stopReaderInstance(inst) {
|
||||
if (inst.audio) {
|
||||
inst.audio.pause();
|
||||
inst.audio.currentTime = 0;
|
||||
}
|
||||
stopReaderHighlightLoop(inst);
|
||||
clearReaderHighlights(inst);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Highlighting
|
||||
// ============================================
|
||||
|
||||
function startReaderHighlightLoop(inst) {
|
||||
cancelAnimationFrame(inst.animFrameId);
|
||||
|
||||
function loop() {
|
||||
if (!inst.audio || inst.audio.paused) return;
|
||||
const currentTime = inst.audio.currentTime;
|
||||
|
||||
const activeAiIndex = inst.transcription.findIndex(w => currentTime >= w.start && currentTime < w.end);
|
||||
if (activeAiIndex !== -1) {
|
||||
const activeTextIndex = inst.wordMap.findIndex(i => i === activeAiIndex);
|
||||
if (activeTextIndex !== -1) {
|
||||
const activeSpan = inst.wordSpans[activeTextIndex];
|
||||
if (activeSpan !== inst.lastWordSpan) {
|
||||
if (inst.lastWordSpan) inst.lastWordSpan.classList.remove('current-word');
|
||||
activeSpan.classList.add('current-word');
|
||||
|
||||
const rect = activeSpan.getBoundingClientRect();
|
||||
if (rect.top < window.innerHeight * 0.25 || rect.bottom > window.innerHeight * 0.75) {
|
||||
activeSpan.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
inst.lastWordSpan = activeSpan;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const activeSentence = inst.sentenceData.find(s => currentTime >= s.startTime && currentTime <= s.endTime);
|
||||
if (activeSentence && activeSentence.spans !== inst.lastSentenceSpans) {
|
||||
if (inst.lastSentenceSpans && inst.lastSentenceSpans.length) {
|
||||
inst.lastSentenceSpans.forEach(s => s.classList.remove('current-sentence-bg'));
|
||||
}
|
||||
activeSentence.spans.forEach(s => s.classList.add('current-sentence-bg'));
|
||||
inst.lastSentenceSpans = activeSentence.spans;
|
||||
}
|
||||
|
||||
inst.animFrameId = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
inst.animFrameId = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
function stopReaderHighlightLoop(inst) {
|
||||
cancelAnimationFrame(inst.animFrameId);
|
||||
}
|
||||
|
||||
function clearReaderHighlights(inst) {
|
||||
if (inst.lastWordSpan) {
|
||||
inst.lastWordSpan.classList.remove('current-word');
|
||||
inst.lastWordSpan = null;
|
||||
}
|
||||
if (inst.lastSentenceSpans && inst.lastSentenceSpans.length) {
|
||||
inst.lastSentenceSpans.forEach(s => s.classList.remove('current-sentence-bg'));
|
||||
inst.lastSentenceSpans = [];
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Button State
|
||||
// ============================================
|
||||
|
||||
function updateReaderButton(state) {
|
||||
const btn = document.getElementById('reader-floating-btn');
|
||||
if (!btn) return;
|
||||
|
||||
const textEl = document.getElementById('reader-btn-text');
|
||||
const playIcon = document.getElementById('reader-btn-play');
|
||||
const pauseIcon = document.getElementById('reader-btn-pause');
|
||||
|
||||
if (readerStarted) {
|
||||
if (textEl) textEl.style.display = 'none';
|
||||
btn.classList.add('active-mode');
|
||||
|
||||
if (state === 'playing') {
|
||||
playIcon.style.display = 'none';
|
||||
pauseIcon.style.display = 'block';
|
||||
} else {
|
||||
playIcon.style.display = 'block';
|
||||
pauseIcon.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Utility
|
||||
// ============================================
|
||||
|
||||
function base64ToBlob(base64, mimeType) {
|
||||
const byteCharacters = atob(base64);
|
||||
const byteNumbers = new Array(byteCharacters.length);
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
return new Blob([byteArray], { type: mimeType });
|
||||
}
|
||||
|
||||
function addReaderStyles() {
|
||||
if (document.getElementById('readerStyles')) return;
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = 'readerStyles';
|
||||
style.textContent = `
|
||||
.reader-chapter { margin-bottom: 48px; }
|
||||
.reader-chapter-title {
|
||||
font-family: var(--font-serif); font-size: 1.75rem; font-weight: 700;
|
||||
color: var(--text-primary); margin-bottom: 24px; padding-bottom: 12px;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
}
|
||||
.reader-block { position: relative; margin-bottom: 16px; padding: 8px 16px; border-radius: var(--border-radius-sm); transition: background 0.2s; }
|
||||
.reader-content { font-family: var(--font-serif); font-size: 1.125rem; line-height: 1.8; }
|
||||
.reader-content p { margin-bottom: 1em; }
|
||||
.reader-content h1, .reader-content h2, .reader-content h3 { font-family: var(--font-serif); margin-top: 1.5em; margin-bottom: 0.5em; }
|
||||
.reader-image-block { text-align: center; margin: 24px 0; }
|
||||
.reader-image-block img {
|
||||
max-width: 100%; height: auto; border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1); display: block; margin: 0 auto;
|
||||
}
|
||||
.reader-image { max-width: 100%; height: auto; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); display: block; margin: 16px auto; }
|
||||
.reader-image-placeholder { text-align: center; padding: 40px; background: #f8fafc; border: 2px dashed #e2e8f0; border-radius: 12px; }
|
||||
.reader-word { cursor: pointer; padding: 1px 0; border-radius: 3px; transition: background 0.15s, color 0.15s; }
|
||||
.reader-word:hover { background: #e3f2fd; }
|
||||
.reader-word.current-word { color: #3d4e81; text-decoration: underline; text-decoration-thickness: 3px; text-underline-offset: 3px; font-weight: 700; }
|
||||
.current-sentence-bg { -webkit-box-decoration-break: clone; box-decoration-break: clone; background-color: #e0e7ff; padding: 0.1em 0.2em; margin: 0 -0.15em; border-radius: 6px; }
|
||||
#reader-floating-btn {
|
||||
position: fixed; top: 80px; right: 24px; height: 56px; min-width: 56px; padding: 0 20px;
|
||||
border-radius: 28px; background: linear-gradient(135deg, var(--primary-color) 0%, #7c3aed 100%);
|
||||
border: none; color: white; box-shadow: 0 4px 15px rgba(0,0,0,0.25);
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px; cursor: pointer;
|
||||
z-index: 1050; transition: transform 0.2s, box-shadow 0.2s;
|
||||
font-family: var(--font-sans); font-weight: 600; font-size: 1rem;
|
||||
}
|
||||
#reader-floating-btn:hover { transform: scale(1.05); box-shadow: 0 6px 20px rgba(0,0,0,0.3); }
|
||||
#reader-floating-btn:active { transform: scale(0.95); }
|
||||
#reader-floating-btn.active-mode { width: 56px; padding: 0; border-radius: 50%; }
|
||||
#reader-floating-btn.active-mode #reader-btn-text { display: none; }
|
||||
#reader-chapter-nav {
|
||||
position: fixed; top: 50%; transform: translateY(-50%);
|
||||
background: rgba(255,255,255,0.9); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
||||
border-radius: 20px; padding: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
z-index: 1000; max-height: 70vh; overflow-y: auto;
|
||||
}
|
||||
#reader-chapter-nav ul { list-style: none; padding: 0; margin: 0; }
|
||||
#reader-chapter-nav a {
|
||||
display: flex; align-items: center; justify-content: center; width: 36px; height: 36px;
|
||||
margin: 4px 0; border-radius: 50%; text-decoration: none; color: var(--text-secondary);
|
||||
font-family: var(--font-sans); font-weight: 600; font-size: 0.85rem; transition: all 0.2s;
|
||||
}
|
||||
#reader-chapter-nav a:hover { background: #e0e7ff; color: var(--primary-color); }
|
||||
#reader-chapter-nav a.active { background: linear-gradient(135deg, var(--primary-color) 0%, #7c3aed 100%); color: white; transform: scale(1.1); }
|
||||
@media (max-width: 768px) {
|
||||
#reader-chapter-nav { display: none !important; }
|
||||
#reader-floating-btn { top: auto; bottom: 20px; right: 20px !important; left: auto !important; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
942
static/js/markdown-editor.js
Normal file
942
static/js/markdown-editor.js
Normal file
@@ -0,0 +1,942 @@
|
||||
/**
|
||||
* Markdown Editor Module - Notion/Obsidian Style
|
||||
* UPDATED: Removed slash command feature (redundant with new-block-line buttons)
|
||||
* UPDATED: Chapter marker no longer auto-creates empty text block
|
||||
* UPDATED: Image upload in new-block-line buttons
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// Editor State
|
||||
// ============================================
|
||||
|
||||
let editorBlocks = [];
|
||||
let activeBlockId = null;
|
||||
let isToolbarClick = false;
|
||||
|
||||
// ============================================
|
||||
// Initialization
|
||||
// ============================================
|
||||
|
||||
function initMarkdownEditor() {
|
||||
const editor = document.getElementById('markdownEditor');
|
||||
|
||||
editor.addEventListener('click', function(e) {
|
||||
if (e.target === editor || e.target.id === 'emptyEditorMessage') {
|
||||
if (editorBlocks.length === 0) {
|
||||
addChapterMarker(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', handleGlobalKeydown);
|
||||
|
||||
document.addEventListener('mousedown', function(e) {
|
||||
if (e.target.closest('.md-block-toolbar') ||
|
||||
e.target.closest('.dropdown-menu') ||
|
||||
e.target.closest('.toolbar-btn')) {
|
||||
isToolbarClick = true;
|
||||
} else {
|
||||
isToolbarClick = false;
|
||||
}
|
||||
});
|
||||
|
||||
console.log('📝 Markdown editor initialized');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// New Block Line Helper
|
||||
// ============================================
|
||||
|
||||
function createNewBlockLine() {
|
||||
const line = document.createElement('div');
|
||||
line.className = 'new-block-line';
|
||||
|
||||
const lineId = 'line_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5);
|
||||
|
||||
line.innerHTML = `
|
||||
<div class="add-line-buttons">
|
||||
<button class="add-line-btn" onclick="addBlockAtLine(this)" title="Add text block">
|
||||
<i class="bi bi-plus"></i>
|
||||
</button>
|
||||
<button class="add-line-btn image-btn" onclick="triggerImageAtLine('${lineId}')" title="Add image">
|
||||
<i class="bi bi-image"></i>
|
||||
</button>
|
||||
<button class="add-line-btn chapter-btn" onclick="addChapterAtLine(this)" title="Add chapter marker">
|
||||
<i class="bi bi-bookmark-star"></i>
|
||||
</button>
|
||||
</div>
|
||||
<input type="file" id="imageLineInput_${lineId}" accept="image/*" hidden
|
||||
onchange="handleImageAtLine(event, this)">
|
||||
`;
|
||||
return line;
|
||||
}
|
||||
|
||||
function triggerImageAtLine(lineId) {
|
||||
const fileInput = document.getElementById(`imageLineInput_${lineId}`);
|
||||
if (fileInput) {
|
||||
fileInput.click();
|
||||
}
|
||||
}
|
||||
|
||||
function handleImageAtLine(event, inputEl) {
|
||||
const file = event.target.files[0];
|
||||
if (!file || !file.type.startsWith('image/')) return;
|
||||
|
||||
const line = inputEl.closest('.new-block-line');
|
||||
if (!line) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = function(e) {
|
||||
const base64Data = e.target.result.split(',')[1];
|
||||
const format = file.type.split('/')[1] === 'jpeg' ? 'jpeg' : file.type.split('/')[1];
|
||||
|
||||
const images = [{
|
||||
data: base64Data,
|
||||
format: format,
|
||||
alt_text: file.name,
|
||||
position: 'before'
|
||||
}];
|
||||
|
||||
const content = ``;
|
||||
|
||||
const blockId = addBlock('image', content, line, images);
|
||||
|
||||
const blockEl = document.getElementById(blockId);
|
||||
if (blockEl) {
|
||||
const contentDiv = blockEl.querySelector('.md-block-content');
|
||||
if (contentDiv) {
|
||||
contentDiv.innerHTML = `
|
||||
<div class="image-block">
|
||||
<img src="${e.target.result}" alt="${file.name}">
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
repairAllNewBlockLines();
|
||||
showNotification('Image added', 'success');
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
event.target.value = '';
|
||||
}
|
||||
|
||||
function ensureNewBlockLineAfter(element) {
|
||||
if (!element) return;
|
||||
const next = element.nextElementSibling;
|
||||
if (next && next.classList.contains('new-block-line')) {
|
||||
return;
|
||||
}
|
||||
element.after(createNewBlockLine());
|
||||
}
|
||||
|
||||
function repairAllNewBlockLines() {
|
||||
const editor = document.getElementById('markdownEditor');
|
||||
if (!editor) return;
|
||||
|
||||
const children = Array.from(editor.children);
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const el = children[i];
|
||||
if (el.classList.contains('new-block-line')) {
|
||||
const prev = el.previousElementSibling;
|
||||
if (!prev || prev.classList.contains('new-block-line')) {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updatedChildren = Array.from(editor.children);
|
||||
for (let i = 0; i < updatedChildren.length; i++) {
|
||||
const el = updatedChildren[i];
|
||||
if (el.classList.contains('md-block') || el.classList.contains('chapter-marker')) {
|
||||
ensureNewBlockLineAfter(el);
|
||||
}
|
||||
}
|
||||
|
||||
const finalChildren = Array.from(editor.children);
|
||||
for (let i = 1; i < finalChildren.length; i++) {
|
||||
if (finalChildren[i].classList.contains('new-block-line') &&
|
||||
finalChildren[i-1].classList.contains('new-block-line')) {
|
||||
finalChildren[i].remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Chapter Markers
|
||||
// ============================================
|
||||
|
||||
function addChapterMarker(chapterNumber = 1, voice = 'af_heart', afterElement = null) {
|
||||
const markerId = 'chapter_' + Date.now();
|
||||
|
||||
const marker = document.createElement('div');
|
||||
marker.className = 'chapter-marker';
|
||||
marker.id = markerId;
|
||||
marker.dataset.chapterNumber = chapterNumber;
|
||||
marker.dataset.voice = voice;
|
||||
|
||||
marker.innerHTML = `
|
||||
<div class="chapter-marker-left">
|
||||
<span class="chapter-label">Chapter</span>
|
||||
<input type="number" class="form-control chapter-number-input"
|
||||
value="${chapterNumber}" min="1"
|
||||
onchange="updateChapterNumber('${markerId}', this.value)">
|
||||
<select class="form-select chapter-voice-select"
|
||||
onchange="updateChapterVoice('${markerId}', this.value)">
|
||||
${getVoiceOptions(voice)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="chapter-actions">
|
||||
<button class="btn btn-success btn-sm" onclick="generateChapterAudio('${markerId}')">
|
||||
<i class="bi bi-play-fill me-1"></i> Generate All
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm" onclick="deleteChapter('${markerId}')">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const editor = document.getElementById('markdownEditor');
|
||||
const emptyMessage = document.getElementById('emptyEditorMessage');
|
||||
|
||||
if (emptyMessage) {
|
||||
emptyMessage.style.display = 'none';
|
||||
}
|
||||
|
||||
if (afterElement) {
|
||||
afterElement.after(marker);
|
||||
} else {
|
||||
editor.appendChild(marker);
|
||||
}
|
||||
|
||||
ensureNewBlockLineAfter(marker);
|
||||
|
||||
return markerId;
|
||||
}
|
||||
|
||||
function getNextChapterNumber() {
|
||||
const chapterMarkers = document.querySelectorAll('.chapter-marker');
|
||||
let maxChapter = 0;
|
||||
chapterMarkers.forEach(m => {
|
||||
const num = parseInt(m.dataset.chapterNumber) || 0;
|
||||
if (num > maxChapter) maxChapter = num;
|
||||
});
|
||||
return maxChapter + 1;
|
||||
}
|
||||
|
||||
function updateChapterNumber(markerId, value) {
|
||||
const marker = document.getElementById(markerId);
|
||||
if (marker) {
|
||||
marker.dataset.chapterNumber = value;
|
||||
}
|
||||
}
|
||||
|
||||
function updateChapterVoice(markerId, value) {
|
||||
const marker = document.getElementById(markerId);
|
||||
if (marker) {
|
||||
marker.dataset.voice = value;
|
||||
}
|
||||
}
|
||||
|
||||
function deleteChapter(markerId) {
|
||||
const marker = document.getElementById(markerId);
|
||||
if (!marker) return;
|
||||
|
||||
const nextLine = marker.nextElementSibling;
|
||||
if (nextLine && nextLine.classList.contains('new-block-line')) {
|
||||
nextLine.remove();
|
||||
}
|
||||
|
||||
marker.remove();
|
||||
|
||||
repairAllNewBlockLines();
|
||||
checkEmptyEditor();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Block Deletion
|
||||
// ============================================
|
||||
|
||||
function deleteBlock(blockId) {
|
||||
const block = document.getElementById(blockId);
|
||||
if (!block) return;
|
||||
|
||||
if (activeBlockId === blockId) {
|
||||
activeBlockId = null;
|
||||
}
|
||||
|
||||
const nextLine = block.nextElementSibling;
|
||||
if (nextLine && nextLine.classList.contains('new-block-line')) {
|
||||
nextLine.remove();
|
||||
}
|
||||
|
||||
block.remove();
|
||||
editorBlocks = editorBlocks.filter(b => b.id !== blockId);
|
||||
|
||||
repairAllNewBlockLines();
|
||||
checkEmptyEditor();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Block Management
|
||||
// ============================================
|
||||
|
||||
function addBlock(type = 'paragraph', content = '', afterElement = null, images = []) {
|
||||
const blockId = 'block_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5);
|
||||
|
||||
const block = document.createElement('div');
|
||||
block.className = 'md-block';
|
||||
block.id = blockId;
|
||||
block.dataset.blockId = blockId;
|
||||
block.dataset.blockType = type;
|
||||
block.dataset.ttsText = '';
|
||||
|
||||
const renderedContent = renderBlockContent(type, content, blockId, images);
|
||||
const isImageBlock = (type === 'image');
|
||||
|
||||
if (isImageBlock) {
|
||||
block.innerHTML = `
|
||||
<div class="block-actions-indicator">
|
||||
<button class="action-indicator-btn delete-block-btn" onclick="deleteBlock('${blockId}')" title="Delete block">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="md-block-content">${renderedContent}</div>
|
||||
<div class="md-block-edit">
|
||||
<textarea class="md-block-textarea"
|
||||
placeholder="Start typing..."
|
||||
oninput="handleBlockInput(event, '${blockId}')"
|
||||
onkeydown="handleBlockKeydown(event, '${blockId}')"
|
||||
onblur="handleBlockBlur(event, '${blockId}')">${content}</textarea>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
block.innerHTML = `
|
||||
<div class="md-block-toolbar">
|
||||
<button class="toolbar-btn" onmousedown="event.preventDefault(); applyFormat('bold')" title="Bold">
|
||||
<i class="bi bi-type-bold"></i>
|
||||
</button>
|
||||
<button class="toolbar-btn" onmousedown="event.preventDefault(); applyFormat('italic')" title="Italic">
|
||||
<i class="bi bi-type-italic"></i>
|
||||
</button>
|
||||
<div class="toolbar-divider"></div>
|
||||
<div class="dropdown">
|
||||
<button class="toolbar-btn dropdown-toggle" data-bs-toggle="dropdown" title="Change Case" onmousedown="event.preventDefault()">
|
||||
Aa
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="#" onmousedown="event.preventDefault(); changeCase('sentence')">Sentence case</a></li>
|
||||
<li><a class="dropdown-item" href="#" onmousedown="event.preventDefault(); changeCase('lower')">lowercase</a></li>
|
||||
<li><a class="dropdown-item" href="#" onmousedown="event.preventDefault(); changeCase('upper')">UPPERCASE</a></li>
|
||||
<li><a class="dropdown-item" href="#" onmousedown="event.preventDefault(); changeCase('title')">Title Case</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="toolbar-divider"></div>
|
||||
<button class="toolbar-btn" onmousedown="event.preventDefault(); openTtsEditor('${blockId}', getBlockContent('${blockId}'))" title="Edit TTS Text">
|
||||
<i class="bi bi-mic"></i>
|
||||
</button>
|
||||
<button class="toolbar-btn" onmousedown="event.preventDefault(); generateBlockAudio('${blockId}')" title="Generate Audio">
|
||||
<i class="bi bi-soundwave"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="block-actions-indicator">
|
||||
<button class="action-indicator-btn edit-block-btn" onclick="enterEditMode('${blockId}')" title="Click to edit">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button class="action-indicator-btn delete-block-btn" onclick="deleteBlock('${blockId}')" title="Delete block">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="audio-indicator no-audio" title="No audio generated">
|
||||
<i class="bi bi-soundwave"></i>
|
||||
</div>
|
||||
|
||||
<div class="md-block-content">${renderedContent}</div>
|
||||
|
||||
<div class="md-block-edit">
|
||||
<textarea class="md-block-textarea"
|
||||
placeholder="Start typing..."
|
||||
oninput="handleBlockInput(event, '${blockId}')"
|
||||
onkeydown="handleBlockKeydown(event, '${blockId}')"
|
||||
onblur="handleBlockBlur(event, '${blockId}')">${content}</textarea>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const editor = document.getElementById('markdownEditor');
|
||||
|
||||
if (afterElement) {
|
||||
if (afterElement.classList.contains('new-block-line')) {
|
||||
afterElement.after(block);
|
||||
} else {
|
||||
const nextSibling = afterElement.nextElementSibling;
|
||||
if (nextSibling && nextSibling.classList.contains('new-block-line')) {
|
||||
nextSibling.after(block);
|
||||
} else {
|
||||
afterElement.after(block);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
editor.appendChild(block);
|
||||
}
|
||||
|
||||
editorBlocks.push({
|
||||
id: blockId,
|
||||
type: type,
|
||||
content: content,
|
||||
images: images
|
||||
});
|
||||
|
||||
if (!content && type !== 'image') {
|
||||
setTimeout(() => enterEditMode(blockId), 100);
|
||||
}
|
||||
|
||||
return blockId;
|
||||
}
|
||||
|
||||
function addBlockAtLine(button) {
|
||||
const line = button.closest('.new-block-line');
|
||||
const blockId = addBlock('paragraph', '', line);
|
||||
repairAllNewBlockLines();
|
||||
}
|
||||
|
||||
function addChapterAtLine(button) {
|
||||
const line = button.closest('.new-block-line');
|
||||
const nextChapterNum = getNextChapterNumber();
|
||||
addChapterMarker(nextChapterNum, 'af_heart', line);
|
||||
repairAllNewBlockLines();
|
||||
}
|
||||
|
||||
function renderBlockContent(type, content, blockId, images) {
|
||||
if (!content) {
|
||||
return `<span class="md-block-placeholder">Click to edit</span>`;
|
||||
}
|
||||
|
||||
if (type === 'image') {
|
||||
if (images && images.length > 0) {
|
||||
const img = images[0];
|
||||
if (img.data) {
|
||||
return `<div class="image-block">
|
||||
<img src="data:image/${img.format || 'png'};base64,${img.data}" alt="${img.alt_text || 'Image'}">
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (blockId) {
|
||||
const blockData = editorBlocks.find(b => b.id === blockId);
|
||||
if (blockData && blockData.images && blockData.images.length > 0) {
|
||||
const img = blockData.images[0];
|
||||
if (img.data) {
|
||||
return `<div class="image-block">
|
||||
<img src="data:image/${img.format || 'png'};base64,${img.data}" alt="${img.alt_text || 'Image'}">
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dataUriMatch = content.match(/!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)/);
|
||||
if (dataUriMatch) {
|
||||
return `<div class="image-block">
|
||||
<img src="${dataUriMatch[2]}" alt="${dataUriMatch[1] || 'Image'}">
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `<div class="image-block">
|
||||
<div class="image-upload-placeholder">
|
||||
<i class="bi bi-image"></i>
|
||||
<p>Click to upload an image</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
let safeContent = content;
|
||||
if (content.length > 10000 && content.includes('base64,')) {
|
||||
safeContent = content.replace(
|
||||
/!\[([^\]]*)\]\(data:image\/[^;]+;base64,[^)]+\)/g,
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const html = marked.parse(safeContent, { breaks: true, gfm: true });
|
||||
return html;
|
||||
} catch (e) {
|
||||
console.warn('marked.js parse error, rendering as plain text:', e.message);
|
||||
return `<p>${escapeHtml(content)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function getBlockContent(blockId) {
|
||||
const block = document.getElementById(blockId);
|
||||
if (block) {
|
||||
const textarea = block.querySelector('.md-block-textarea');
|
||||
return textarea ? textarea.value : '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Edit Mode
|
||||
// ============================================
|
||||
|
||||
function enterEditMode(blockId) {
|
||||
if (activeBlockId && activeBlockId !== blockId) {
|
||||
exitEditMode(activeBlockId);
|
||||
}
|
||||
|
||||
const block = document.getElementById(blockId);
|
||||
if (!block) return;
|
||||
|
||||
block.classList.add('editing');
|
||||
activeBlockId = blockId;
|
||||
|
||||
const textarea = block.querySelector('.md-block-textarea');
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
||||
autoResizeTextarea(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
function handleBlockBlur(event, blockId) {
|
||||
if (isToolbarClick) {
|
||||
isToolbarClick = false;
|
||||
setTimeout(() => {
|
||||
const block = document.getElementById(blockId);
|
||||
if (block && block.classList.contains('editing')) {
|
||||
const textarea = block.querySelector('.md-block-textarea');
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
}
|
||||
}
|
||||
}, 10);
|
||||
return;
|
||||
}
|
||||
|
||||
exitEditMode(blockId);
|
||||
}
|
||||
|
||||
function exitEditMode(blockId) {
|
||||
const block = document.getElementById(blockId);
|
||||
if (!block) return;
|
||||
|
||||
block.classList.remove('editing');
|
||||
|
||||
const textarea = block.querySelector('.md-block-textarea');
|
||||
const contentDiv = block.querySelector('.md-block-content');
|
||||
|
||||
if (textarea && contentDiv) {
|
||||
const content = textarea.value.trim();
|
||||
const blockType = block.dataset.blockType || 'paragraph';
|
||||
|
||||
const blockData = editorBlocks.find(b => b.id === blockId);
|
||||
const images = blockData ? (blockData.images || []) : [];
|
||||
|
||||
if (content === '') {
|
||||
contentDiv.innerHTML = renderBlockContent(blockType, '', blockId, images);
|
||||
|
||||
if (blockData) {
|
||||
blockData.content = '';
|
||||
}
|
||||
} else {
|
||||
contentDiv.innerHTML = renderBlockContent(blockType, content, blockId, images);
|
||||
|
||||
if (blockData) {
|
||||
blockData.content = content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activeBlockId === blockId) {
|
||||
activeBlockId = null;
|
||||
}
|
||||
|
||||
repairAllNewBlockLines();
|
||||
}
|
||||
|
||||
function autoResizeTextarea(textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.max(textarea.scrollHeight, 60) + 'px';
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Input Handling
|
||||
// ============================================
|
||||
|
||||
function handleBlockInput(event, blockId) {
|
||||
const textarea = event.target;
|
||||
autoResizeTextarea(textarea);
|
||||
}
|
||||
|
||||
function handleBlockKeydown(event, blockId) {
|
||||
const textarea = event.target;
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
textarea.blur();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
|
||||
const block = document.getElementById(blockId);
|
||||
|
||||
exitEditMode(blockId);
|
||||
|
||||
addBlock('paragraph', '', block);
|
||||
|
||||
repairAllNewBlockLines();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Backspace' && textarea.value === '') {
|
||||
event.preventDefault();
|
||||
|
||||
const block = document.getElementById(blockId);
|
||||
let prevBlock = block.previousElementSibling;
|
||||
|
||||
while (prevBlock && !prevBlock.classList.contains('md-block')) {
|
||||
prevBlock = prevBlock.previousElementSibling;
|
||||
}
|
||||
|
||||
const nextLine = block.nextElementSibling;
|
||||
if (nextLine && nextLine.classList.contains('new-block-line')) {
|
||||
nextLine.remove();
|
||||
}
|
||||
|
||||
block.remove();
|
||||
editorBlocks = editorBlocks.filter(b => b.id !== blockId);
|
||||
activeBlockId = null;
|
||||
|
||||
repairAllNewBlockLines();
|
||||
|
||||
if (prevBlock) {
|
||||
enterEditMode(prevBlock.id);
|
||||
}
|
||||
|
||||
checkEmptyEditor();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function handleGlobalKeydown(event) {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
||||
event.preventDefault();
|
||||
saveProject();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Text Formatting
|
||||
// ============================================
|
||||
|
||||
function applyFormat(format) {
|
||||
if (!activeBlockId) return;
|
||||
|
||||
const block = document.getElementById(activeBlockId);
|
||||
const textarea = block.querySelector('.md-block-textarea');
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const selectedText = textarea.value.substring(start, end);
|
||||
|
||||
if (start === end) return;
|
||||
|
||||
let wrapper = '';
|
||||
switch (format) {
|
||||
case 'bold': wrapper = '**'; break;
|
||||
case 'italic': wrapper = '*'; break;
|
||||
}
|
||||
|
||||
const newText = textarea.value.substring(0, start) +
|
||||
wrapper + selectedText + wrapper +
|
||||
textarea.value.substring(end);
|
||||
|
||||
textarea.value = newText;
|
||||
textarea.setSelectionRange(start + wrapper.length, end + wrapper.length);
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
function changeCase(caseType) {
|
||||
if (!activeBlockId) return;
|
||||
|
||||
const block = document.getElementById(activeBlockId);
|
||||
const textarea = block.querySelector('.md-block-textarea');
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
let selectedText = textarea.value.substring(start, end);
|
||||
|
||||
if (start === end) return;
|
||||
|
||||
switch (caseType) {
|
||||
case 'lower': selectedText = selectedText.toLowerCase(); break;
|
||||
case 'upper': selectedText = selectedText.toUpperCase(); break;
|
||||
case 'sentence': selectedText = selectedText.toLowerCase().replace(/(^\w|\.\s+\w)/g, c => c.toUpperCase()); break;
|
||||
case 'title': selectedText = selectedText.toLowerCase().replace(/\b\w/g, c => c.toUpperCase()); break;
|
||||
}
|
||||
|
||||
textarea.value = textarea.value.substring(0, start) + selectedText + textarea.value.substring(end);
|
||||
textarea.setSelectionRange(start, start + selectedText.length);
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Image Block Handling
|
||||
// ============================================
|
||||
|
||||
function convertToImageBlock(blockId) {
|
||||
const block = document.getElementById(blockId);
|
||||
if (!block) return;
|
||||
|
||||
block.dataset.blockType = 'image';
|
||||
|
||||
const actionsIndicator = block.querySelector('.block-actions-indicator');
|
||||
if (actionsIndicator) {
|
||||
const editBtn = actionsIndicator.querySelector('.edit-block-btn');
|
||||
if (editBtn) editBtn.remove();
|
||||
}
|
||||
|
||||
const audioIndicator = block.querySelector('.audio-indicator');
|
||||
if (audioIndicator) audioIndicator.remove();
|
||||
const toolbar = block.querySelector('.md-block-toolbar');
|
||||
if (toolbar) toolbar.remove();
|
||||
|
||||
const contentDiv = block.querySelector('.md-block-content');
|
||||
contentDiv.innerHTML = `
|
||||
<div class="image-block" onclick="triggerImageUpload('${blockId}')">
|
||||
<input type="file" id="imageInput_${blockId}" accept="image/*" hidden
|
||||
onchange="handleImageUpload(event, '${blockId}')">
|
||||
<div class="image-upload-placeholder">
|
||||
<i class="bi bi-image"></i>
|
||||
<p>Click to upload or drag & drop an image</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const imageBlock = contentDiv.querySelector('.image-block');
|
||||
|
||||
imageBlock.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
imageBlock.classList.add('drag-over');
|
||||
});
|
||||
|
||||
imageBlock.addEventListener('dragleave', () => {
|
||||
imageBlock.classList.remove('drag-over');
|
||||
});
|
||||
|
||||
imageBlock.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
imageBlock.classList.remove('drag-over');
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0 && files[0].type.startsWith('image/')) {
|
||||
processImageFile(files[0], blockId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function triggerImageUpload(blockId) {
|
||||
document.getElementById(`imageInput_${blockId}`).click();
|
||||
}
|
||||
|
||||
function handleImageUpload(event, blockId) {
|
||||
const file = event.target.files[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
processImageFile(file, blockId);
|
||||
}
|
||||
}
|
||||
|
||||
function processImageFile(file, blockId) {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = function(e) {
|
||||
const base64Data = e.target.result.split(',')[1];
|
||||
const format = file.type.split('/')[1];
|
||||
|
||||
const block = document.getElementById(blockId);
|
||||
const textarea = block.querySelector('.md-block-textarea');
|
||||
const contentDiv = block.querySelector('.md-block-content');
|
||||
|
||||
textarea.value = ``;
|
||||
|
||||
contentDiv.innerHTML = `
|
||||
<div class="image-block">
|
||||
<img src="${e.target.result}" alt="${file.name}">
|
||||
</div>
|
||||
`;
|
||||
|
||||
const blockData = editorBlocks.find(b => b.id === blockId);
|
||||
if (blockData) {
|
||||
blockData.content = textarea.value;
|
||||
blockData.images = [{
|
||||
data: base64Data,
|
||||
format: format,
|
||||
alt_text: file.name,
|
||||
position: 'before'
|
||||
}];
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Utility Functions
|
||||
// ============================================
|
||||
|
||||
function checkEmptyEditor() {
|
||||
const editor = document.getElementById('markdownEditor');
|
||||
const blocks = editor.querySelectorAll('.md-block, .chapter-marker');
|
||||
const emptyMessage = document.getElementById('emptyEditorMessage');
|
||||
|
||||
if (blocks.length === 0) {
|
||||
editor.querySelectorAll('.new-block-line').forEach(line => line.remove());
|
||||
|
||||
if (emptyMessage) {
|
||||
emptyMessage.style.display = 'block';
|
||||
}
|
||||
} else {
|
||||
if (emptyMessage) {
|
||||
emptyMessage.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Content Collection
|
||||
// ============================================
|
||||
|
||||
function collectEditorContent() {
|
||||
const editor = document.getElementById('markdownEditor');
|
||||
const chapters = [];
|
||||
let currentChapter = null;
|
||||
let blockOrder = 0;
|
||||
|
||||
const elements = editor.children;
|
||||
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const el = elements[i];
|
||||
|
||||
if (el.classList.contains('chapter-marker')) {
|
||||
if (currentChapter) {
|
||||
chapters.push(currentChapter);
|
||||
}
|
||||
|
||||
currentChapter = {
|
||||
chapter_number: parseInt(el.dataset.chapterNumber) || 1,
|
||||
voice: el.dataset.voice || 'af_heart',
|
||||
blocks: []
|
||||
};
|
||||
blockOrder = 0;
|
||||
|
||||
} else if (el.classList.contains('md-block')) {
|
||||
if (!currentChapter) {
|
||||
currentChapter = {
|
||||
chapter_number: 1,
|
||||
voice: 'af_heart',
|
||||
blocks: []
|
||||
};
|
||||
}
|
||||
|
||||
blockOrder++;
|
||||
|
||||
const textarea = el.querySelector('.md-block-textarea');
|
||||
const content = textarea ? textarea.value : '';
|
||||
const blockType = el.dataset.blockType || 'paragraph';
|
||||
const blockData = editorBlocks.find(b => b.id === el.id);
|
||||
const hasImages = blockData && blockData.images && blockData.images.length > 0;
|
||||
|
||||
if (content.trim() || (blockType === 'image' && hasImages)) {
|
||||
currentChapter.blocks.push({
|
||||
block_order: blockOrder,
|
||||
block_type: blockType,
|
||||
content: content,
|
||||
tts_text: el.dataset.ttsText || '',
|
||||
audio_data: blockData?.audio_data || '',
|
||||
audio_format: blockData?.audio_format || 'mp3',
|
||||
transcription: blockData?.transcription || [],
|
||||
images: blockData?.images || []
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentChapter && currentChapter.blocks.length > 0) {
|
||||
chapters.push(currentChapter);
|
||||
}
|
||||
|
||||
return chapters;
|
||||
}
|
||||
|
||||
function renderProjectInEditor(projectData) {
|
||||
const editor = document.getElementById('markdownEditor');
|
||||
editor.innerHTML = '';
|
||||
editorBlocks = [];
|
||||
|
||||
const emptyMessage = document.getElementById('emptyEditorMessage');
|
||||
if (emptyMessage) {
|
||||
emptyMessage.style.display = 'none';
|
||||
}
|
||||
|
||||
for (const chapter of projectData.chapters) {
|
||||
addChapterMarker(chapter.chapter_number, chapter.voice);
|
||||
|
||||
for (const block of chapter.blocks) {
|
||||
const lastChild = editor.lastElementChild;
|
||||
|
||||
const blockId = addBlock(
|
||||
block.block_type,
|
||||
block.content,
|
||||
lastChild,
|
||||
block.images || []
|
||||
);
|
||||
|
||||
const blockData = editorBlocks.find(b => b.id === blockId);
|
||||
if (blockData) {
|
||||
blockData.audio_data = block.audio_data;
|
||||
blockData.audio_format = block.audio_format;
|
||||
blockData.transcription = block.transcription;
|
||||
blockData.tts_text = block.tts_text;
|
||||
}
|
||||
|
||||
const blockEl = document.getElementById(blockId);
|
||||
if (blockEl && block.tts_text) {
|
||||
blockEl.dataset.ttsText = block.tts_text;
|
||||
}
|
||||
|
||||
if (block.block_type === 'image' && block.images && block.images.length > 0) {
|
||||
const img = block.images[0];
|
||||
if (img.data) {
|
||||
const contentDiv = blockEl.querySelector('.md-block-content');
|
||||
if (contentDiv) {
|
||||
contentDiv.innerHTML = `<div class="image-block">
|
||||
<img src="data:image/${img.format || 'png'};base64,${img.data}" alt="${img.alt_text || 'Image'}">
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (block.audio_data && block.block_type !== 'image') {
|
||||
const indicator = blockEl.querySelector('.audio-indicator');
|
||||
if (indicator) {
|
||||
indicator.classList.remove('no-audio');
|
||||
indicator.classList.add('has-audio');
|
||||
indicator.title = 'Audio generated';
|
||||
}
|
||||
}
|
||||
|
||||
ensureNewBlockLineAfter(blockEl);
|
||||
}
|
||||
}
|
||||
|
||||
repairAllNewBlockLines();
|
||||
}
|
||||
228
static/js/pdf-handler.js
Normal file
228
static/js/pdf-handler.js
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Document Handler Module (PDF, DOCX, DOC)
|
||||
* UPDATED: Updates workflow progress after document load
|
||||
*/
|
||||
|
||||
function initPdfHandler() {
|
||||
const uploadZone = document.getElementById('uploadZone');
|
||||
const docInput = document.getElementById('docInput');
|
||||
|
||||
if (!uploadZone || !docInput) return;
|
||||
|
||||
uploadZone.addEventListener('click', (e) => {
|
||||
if (e.target.closest('button')) return;
|
||||
docInput.click();
|
||||
});
|
||||
|
||||
docInput.addEventListener('change', (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
handleDocumentFile(file);
|
||||
}
|
||||
});
|
||||
|
||||
uploadZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
uploadZone.classList.add('drag-over');
|
||||
});
|
||||
|
||||
uploadZone.addEventListener('dragleave', () => {
|
||||
uploadZone.classList.remove('drag-over');
|
||||
});
|
||||
|
||||
uploadZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
uploadZone.classList.remove('drag-over');
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
const file = files[0];
|
||||
const name = file.name.toLowerCase();
|
||||
|
||||
if (name.endsWith('.pdf') || name.endsWith('.docx') || name.endsWith('.doc')) {
|
||||
handleDocumentFile(file);
|
||||
} else {
|
||||
alert('Please drop a valid PDF, DOCX, or DOC file.');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('📄 Document handler initialized (PDF, DOCX, DOC)');
|
||||
}
|
||||
|
||||
function handleDocumentFile(file) {
|
||||
const name = file.name.toLowerCase();
|
||||
|
||||
if (name.endsWith('.pdf')) {
|
||||
handlePdfFile(file);
|
||||
} else if (name.endsWith('.docx') || name.endsWith('.doc')) {
|
||||
handleWordFile(file);
|
||||
} else {
|
||||
alert('Unsupported file type. Please upload a PDF, DOCX, or DOC file.');
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePdfFile(file) {
|
||||
if (!file.name.toLowerCase().endsWith('.pdf')) {
|
||||
alert('Please select a valid PDF file.');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoader('Processing PDF...', `Extracting content from ${file.name}`);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/upload-pdf', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
console.log(`✅ PDF processed: ${data.page_count} pages, ${data.blocks.length} blocks`);
|
||||
|
||||
const projectName = file.name.replace('.pdf', '');
|
||||
document.getElementById('projectName').value = projectName;
|
||||
currentProject.name = projectName;
|
||||
|
||||
renderDocumentBlocks(data.blocks);
|
||||
|
||||
document.getElementById('uploadSection').style.display = 'none';
|
||||
document.getElementById('editorSection').style.display = 'block';
|
||||
|
||||
hideLoader();
|
||||
showNotification(`PDF processed: ${data.blocks.length} blocks extracted`, 'success');
|
||||
updateWorkflowProgress('edit');
|
||||
|
||||
} catch (error) {
|
||||
hideLoader();
|
||||
console.error('PDF processing error:', error);
|
||||
alert('Failed to process PDF: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWordFile(file) {
|
||||
const name = file.name.toLowerCase();
|
||||
if (!name.endsWith('.docx') && !name.endsWith('.doc')) {
|
||||
alert('Please select a valid DOCX or DOC file.');
|
||||
return;
|
||||
}
|
||||
|
||||
const fileType = name.endsWith('.docx') ? 'DOCX' : 'DOC';
|
||||
showLoader(`Processing ${fileType}...`, `Extracting content from ${file.name}`);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/upload-docx', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
console.log(`✅ ${fileType} processed: ${data.blocks.length} blocks`);
|
||||
|
||||
const projectName = file.name.replace(/\.(docx|doc)$/i, '');
|
||||
document.getElementById('projectName').value = projectName;
|
||||
currentProject.name = projectName;
|
||||
|
||||
renderDocumentBlocks(data.blocks);
|
||||
|
||||
document.getElementById('uploadSection').style.display = 'none';
|
||||
document.getElementById('editorSection').style.display = 'block';
|
||||
|
||||
hideLoader();
|
||||
showNotification(`${fileType} processed: ${data.blocks.length} blocks extracted`, 'success');
|
||||
updateWorkflowProgress('edit');
|
||||
|
||||
} catch (error) {
|
||||
hideLoader();
|
||||
console.error(`${fileType} processing error:`, error);
|
||||
alert(`Failed to process ${fileType}: ` + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderDocumentBlocks(blocks) {
|
||||
const editor = document.getElementById('markdownEditor');
|
||||
editor.innerHTML = '';
|
||||
editorBlocks = [];
|
||||
|
||||
const emptyMessage = document.getElementById('emptyEditorMessage');
|
||||
if (emptyMessage) {
|
||||
emptyMessage.style.display = 'none';
|
||||
}
|
||||
|
||||
addChapterMarker(1);
|
||||
|
||||
for (const block of blocks) {
|
||||
let type = 'paragraph';
|
||||
let content = block.content || '';
|
||||
|
||||
if (block.type === 'image') {
|
||||
type = 'image';
|
||||
} else if (block.type === 'heading1' || content.startsWith('# ')) {
|
||||
type = 'heading1';
|
||||
} else if (block.type === 'heading2' || content.startsWith('## ')) {
|
||||
type = 'heading2';
|
||||
} else if (block.type === 'heading3' || content.startsWith('### ')) {
|
||||
type = 'heading3';
|
||||
} else if (block.type === 'list_item' || content.startsWith('- ')) {
|
||||
type = 'bulletList';
|
||||
} else if (block.type === 'quote' || content.startsWith('> ')) {
|
||||
type = 'quote';
|
||||
} else if (block.type === 'table') {
|
||||
type = 'table';
|
||||
}
|
||||
|
||||
let images = [];
|
||||
if (block.type === 'image' && block.data) {
|
||||
images = [{
|
||||
data: block.data,
|
||||
format: block.format || 'png',
|
||||
alt_text: 'Document Image',
|
||||
position: 'before'
|
||||
}];
|
||||
content = ``;
|
||||
}
|
||||
|
||||
const lastChild = editor.lastElementChild;
|
||||
const blockId = addBlock(type, content, lastChild, images);
|
||||
|
||||
if (block.type === 'image' && block.data) {
|
||||
const blockEl = document.getElementById(blockId);
|
||||
if (blockEl) {
|
||||
const contentDiv = blockEl.querySelector('.md-block-content');
|
||||
if (contentDiv) {
|
||||
contentDiv.innerHTML = `
|
||||
<div class="image-block">
|
||||
<img src="data:image/${block.format || 'png'};base64,${block.data}" alt="Document Image">
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const blockEl = document.getElementById(blockId);
|
||||
if (blockEl) {
|
||||
ensureNewBlockLineAfter(blockEl);
|
||||
}
|
||||
}
|
||||
|
||||
repairAllNewBlockLines();
|
||||
}
|
||||
|
||||
function renderPdfBlocks(blocks) {
|
||||
renderDocumentBlocks(blocks);
|
||||
}
|
||||
Reference in New Issue
Block a user