Audiobook Maker Pro v4.2 — production ready
This commit is contained in:
827
static/css/markdown-editor.css
Normal file
827
static/css/markdown-editor.css
Normal file
@@ -0,0 +1,827 @@
|
||||
/* ============================================
|
||||
Markdown Editor Styles
|
||||
UPDATED: Added Audiobook Maker Panel (fixed)
|
||||
UPDATED: Added starting-block highlight
|
||||
UPDATED: Added Sidebar Outline & Section Dividers
|
||||
============================================= */
|
||||
|
||||
/* ============================================
|
||||
Audiobook Maker Panel (Fixed at top of editor)
|
||||
============================================= */
|
||||
|
||||
.audiobook-maker-panel {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 200;
|
||||
background: linear-gradient(135deg, #1e1b4b 0%, #312e81 50%, #3730a3 100%);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 16px 24px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
box-shadow: 0 4px 20px rgba(30, 27, 75, 0.35);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.amp-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.amp-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.amp-label {
|
||||
font-weight: 700;
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: rgba(255,255,255,0.7);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.amp-voice-select {
|
||||
min-width: 200px;
|
||||
padding: 8px 12px;
|
||||
border: 2px solid rgba(255,255,255,0.25);
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: white;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.amp-voice-select:focus {
|
||||
outline: none;
|
||||
border-color: rgba(255,255,255,0.6);
|
||||
background: rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
.amp-voice-select option {
|
||||
background: #1e1b4b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Block count input group */
|
||||
.amp-block-count-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.amp-block-count-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: rgba(255,255,255,0.7);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.amp-block-count-input {
|
||||
width: 72px;
|
||||
padding: 8px 10px;
|
||||
border: 2px solid rgba(255,255,255,0.25);
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: white;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
transition: border-color 0.2s;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.amp-block-count-input::-webkit-outer-spin-button,
|
||||
.amp-block-count-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.amp-block-count-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(255,255,255,0.6);
|
||||
background: rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
.amp-count-arrows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.amp-count-arrow {
|
||||
width: 24px;
|
||||
height: 18px;
|
||||
border: none;
|
||||
background: rgba(255,255,255,0.12);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.6rem;
|
||||
transition: background 0.2s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.amp-count-arrow:hover {
|
||||
background: rgba(255,255,255,0.25);
|
||||
}
|
||||
|
||||
/* Starting block indicator */
|
||||
.amp-start-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border: 1.5px solid rgba(255,255,255,0.2);
|
||||
border-radius: 20px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: rgba(255,255,255,0.85);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.amp-start-indicator:hover {
|
||||
background: rgba(255,255,255,0.18);
|
||||
border-color: rgba(255,255,255,0.4);
|
||||
}
|
||||
|
||||
.amp-start-indicator .start-block-num {
|
||||
background: #fbbf24;
|
||||
color: #1e1b4b;
|
||||
font-weight: 800;
|
||||
font-size: 0.72rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.amp-start-indicator.pick-mode {
|
||||
background: rgba(251, 191, 36, 0.2);
|
||||
border-color: #fbbf24;
|
||||
color: #fbbf24;
|
||||
animation: pickPulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pickPulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.3); }
|
||||
50% { box-shadow: 0 0 0 6px rgba(251, 191, 36, 0); }
|
||||
}
|
||||
|
||||
/* Generate button on panel */
|
||||
.amp-generate-btn {
|
||||
padding: 10px 24px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: 0.88rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 2px 10px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.amp-generate-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
.amp-generate-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.amp-generate-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Stats/info line */
|
||||
.amp-info {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(255,255,255,0.1);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.amp-stat {
|
||||
font-size: 0.72rem;
|
||||
color: rgba(255,255,255,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.amp-stat strong {
|
||||
color: rgba(255,255,255,0.8);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Layout for Sidebar and Editor
|
||||
============================================= */
|
||||
|
||||
.editor-layout {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.document-outline {
|
||||
width: 250px;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 120px;
|
||||
background: white;
|
||||
border-radius: var(--border-radius);
|
||||
padding: 16px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
max-height: calc(100vh - 140px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.outline-title {
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.outline-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.outline-list li {
|
||||
padding: 8px 12px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.outline-list li:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
flex: 1;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
min-height: 500px;
|
||||
padding: 24px 48px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Section Dividers (Automated Chapter Markers)
|
||||
============================================= */
|
||||
|
||||
.section-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 32px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.divider-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, #cbd5e1, transparent);
|
||||
}
|
||||
|
||||
.divider-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: #475569;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
padding: 4px 12px;
|
||||
background: #f8fafc;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.section-title:focus {
|
||||
border-color: var(--primary-color);
|
||||
background: white;
|
||||
box-shadow: 0 0 0 2px rgba(79,70,229,0.1);
|
||||
}
|
||||
|
||||
.btn-merge-section {
|
||||
opacity: 0;
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.7rem;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.section-divider:hover .btn-merge-section {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Starting Block Highlight
|
||||
============================================= */
|
||||
|
||||
.md-block.starting-block {
|
||||
border-left: 4px solid #fbbf24 !important;
|
||||
background: rgba(251, 191, 36, 0.06);
|
||||
}
|
||||
|
||||
.md-block.starting-block::before {
|
||||
content: 'START';
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: -4px;
|
||||
background: #fbbf24;
|
||||
color: #1e1b4b;
|
||||
font-size: 0.58rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 0 0 6px 0;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* Blocks in generation range */
|
||||
.md-block.in-gen-range {
|
||||
border-left: 4px solid #a78bfa !important;
|
||||
background: rgba(167, 139, 250, 0.04);
|
||||
}
|
||||
|
||||
/* Pick mode: blocks glow on hover */
|
||||
.editor-pick-mode .md-block:not(.editing):hover {
|
||||
border-color: #fbbf24 !important;
|
||||
background: rgba(251, 191, 36, 0.08);
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
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: -45px; /* Adjust top for new buttons */
|
||||
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;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.md-block.editing .md-block-toolbar {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.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(--primary-color);
|
||||
}
|
||||
|
||||
.toolbar-btn.active {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toolbar-divider {
|
||||
width: 1px;
|
||||
background: var(--border-color);
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
.action-btn-text {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0 6px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.add-line-btn.image-btn:hover {
|
||||
background: #0891b2;
|
||||
}
|
||||
|
||||
/* Section add button - purple */
|
||||
.add-line-btn.section-btn {
|
||||
background: #8b5cf6;
|
||||
}
|
||||
|
||||
.add-line-btn.section-btn:hover {
|
||||
background: #7c3aed;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
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;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Responsive
|
||||
============================================= */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.audiobook-maker-panel {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.amp-left {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.amp-right {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.amp-voice-select {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.amp-info {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editor-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.document-outline {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
top: 0;
|
||||
max-height: 200px;
|
||||
}
|
||||
}
|
||||
1398
static/css/style.css
Normal file
1398
static/css/style.css
Normal file
File diff suppressed because it is too large
Load Diff
1174
static/js/app.js
Normal file
1174
static/js/app.js
Normal file
File diff suppressed because it is too large
Load Diff
223
static/js/generation.js
Normal file
223
static/js/generation.js
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Audio Generation Module
|
||||
* UPDATED: Panel-based generation (no chapter markers)
|
||||
* Generates audio for N blocks starting from the selected starting block
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// Panel-Based Generation
|
||||
// ============================================
|
||||
|
||||
async function generateFromPanel() {
|
||||
const textBlocks = getTextBlocks();
|
||||
|
||||
if (textBlocks.length === 0) {
|
||||
alert('No text blocks found to generate audio for.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!panelState.startingBlockId) {
|
||||
alert('No starting block selected.');
|
||||
return;
|
||||
}
|
||||
|
||||
const startIdx = getTextBlockIndex(panelState.startingBlockId);
|
||||
if (startIdx < 0) {
|
||||
alert('Starting block not found. Please select a valid block.');
|
||||
return;
|
||||
}
|
||||
|
||||
const count = panelState.blockCount || 10;
|
||||
const voice = panelState.voice || 'af_heart';
|
||||
const endIdx = Math.min(startIdx + count, textBlocks.length);
|
||||
|
||||
const blocksToGenerate = [];
|
||||
|
||||
for (let i = startIdx; i < endIdx; i++) {
|
||||
const blockEl = textBlocks[i];
|
||||
const textarea = blockEl.querySelector('.md-block-textarea');
|
||||
const content = textarea ? textarea.value : '';
|
||||
|
||||
if (!content.trim()) continue;
|
||||
|
||||
// Skip image content
|
||||
if (content.trim().startsWith(' !== -1) continue;
|
||||
|
||||
const ttsText = (blockEl.dataset.ttsText && blockEl.dataset.ttsText.trim())
|
||||
? blockEl.dataset.ttsText
|
||||
: content;
|
||||
|
||||
blocksToGenerate.push({
|
||||
id: blockEl.id,
|
||||
text: ttsText,
|
||||
element: blockEl
|
||||
});
|
||||
}
|
||||
|
||||
if (blocksToGenerate.length === 0) {
|
||||
alert('No speakable text blocks found in the selected range.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable generate button
|
||||
const genBtn = document.getElementById('ampGenerateBtn');
|
||||
if (genBtn) genBtn.disabled = true;
|
||||
|
||||
showLoader(`Generating 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;
|
||||
}
|
||||
|
||||
// Store audio data in editorBlocks
|
||||
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;
|
||||
}
|
||||
|
||||
// Update visual indicator
|
||||
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();
|
||||
|
||||
// Re-enable generate button
|
||||
if (genBtn) genBtn.disabled = false;
|
||||
|
||||
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');
|
||||
|
||||
// Advance starting block
|
||||
advanceStartingBlockAfterGeneration(endIdx - startIdx);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Single Block Generation (from toolbar button)
|
||||
// ============================================
|
||||
|
||||
async function generateSingleBlockAudio(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;
|
||||
const voice = panelState.voice || 'af_heart';
|
||||
|
||||
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');
|
||||
|
||||
updateWorkflowProgress('audio-ready');
|
||||
updatePanelUI();
|
||||
|
||||
} catch (error) {
|
||||
hideLoader();
|
||||
console.error('Generation error:', error);
|
||||
alert('Failed to generate audio: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep old function name for backward compatibility
|
||||
function generateBlockAudio(blockId) {
|
||||
generateSingleBlockAudio(blockId);
|
||||
}
|
||||
917
static/js/interactive-reader.js
Normal file
917
static/js/interactive-reader.js
Normal file
@@ -0,0 +1,917 @@
|
||||
/**
|
||||
* Interactive Reader Module — Smart Preload Architecture (v3)
|
||||
*
|
||||
* Loading Strategy:
|
||||
* - Text and timestamps come from in-memory `editorBlocks` (already loaded).
|
||||
* - Audio base64 → Blob URL conversion is DEFERRED until needed.
|
||||
* - When block N plays, preload blob URLs for N+1, N+2 (background).
|
||||
* - At 70% mark of N's audio, ensure N+1 is ready (safety net).
|
||||
* - Memory cap: keep at most MAX_AUDIO_LOADED blob URLs alive;
|
||||
* revoke distant past audio to free browser memory.
|
||||
*
|
||||
* Scroll Strategy:
|
||||
* - Manual navigation (button / outline / word click): scroll block to top.
|
||||
* - Auto-advance (audio ended → next block): NO block scroll — let the
|
||||
* word highlighter smoothly carry the user. Prevents jarring jumps.
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// Reader State
|
||||
// ============================================
|
||||
|
||||
let readerInstances = [];
|
||||
let currentReaderInstance = null;
|
||||
let currentReaderIndex = -1;
|
||||
let readerStarted = false;
|
||||
let readerUICreated = false;
|
||||
|
||||
// Tunables
|
||||
const READER_PRELOAD_AHEAD = 2;
|
||||
const READER_MID_PRELOAD_THRESHOLD = 0.7;
|
||||
const READER_MAX_AUDIO_LOADED = 5;
|
||||
const READER_KEEP_BEHIND = 1;
|
||||
|
||||
// ============================================
|
||||
// Render Reader
|
||||
// ============================================
|
||||
|
||||
function renderInteractiveReader() {
|
||||
const container = document.getElementById('readerContainer');
|
||||
|
||||
if (container.style.maxWidth) container.style.maxWidth = '';
|
||||
|
||||
const chapters = collectEditorContent();
|
||||
|
||||
let hasAudio = false;
|
||||
const allBlocks = [];
|
||||
let outlineHtml = '';
|
||||
let currentIndex = 0;
|
||||
|
||||
for (const chapter of chapters) {
|
||||
if (chapter.blocks.length === 0) continue;
|
||||
|
||||
outlineHtml += `
|
||||
<li onclick="scrollToReaderBlock(${currentIndex})" title="${escapeHtml(chapter.title)}">
|
||||
${escapeHtml(chapter.title)}
|
||||
</li>
|
||||
`;
|
||||
|
||||
let isFirstBlockOfChapter = true;
|
||||
|
||||
for (const block of chapter.blocks) {
|
||||
const blockData = findEditorBlockForContent(block);
|
||||
const isImageBlock = block.block_type === 'image' ||
|
||||
(block.content && block.content.trim().startsWith(');
|
||||
|
||||
allBlocks.push({
|
||||
...block,
|
||||
_editorData: blockData || null,
|
||||
_isImage: isImageBlock,
|
||||
_chapterTitle: isFirstBlockOfChapter ? chapter.title : null
|
||||
});
|
||||
|
||||
isFirstBlockOfChapter = false;
|
||||
|
||||
if (!isImageBlock && blockData && blockData.audio_data) {
|
||||
hasAudio = true;
|
||||
}
|
||||
currentIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
const readerOutlineSidebar = document.getElementById('readerOutlineSidebar');
|
||||
const readerOutlineList = document.getElementById('readerOutlineList');
|
||||
|
||||
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 the panel</p>
|
||||
</div>
|
||||
`;
|
||||
removeReaderUI();
|
||||
if (readerOutlineSidebar) readerOutlineSidebar.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
if (readerOutlineSidebar && readerOutlineList) {
|
||||
readerOutlineSidebar.style.display = 'block';
|
||||
readerOutlineList.innerHTML = outlineHtml || '<li class="text-muted small">No sections found.</li>';
|
||||
}
|
||||
|
||||
let html = '<div class="reader-flow">';
|
||||
|
||||
// Cleanup any previous instances (revoke blob URLs)
|
||||
cleanupAllReaderInstances();
|
||||
readerInstances = [];
|
||||
|
||||
let globalBlockIndex = 0;
|
||||
|
||||
for (const block of allBlocks) {
|
||||
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 {
|
||||
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>`;
|
||||
|
||||
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,
|
||||
wordSpans: [],
|
||||
wordMap: [],
|
||||
sentenceData: [],
|
||||
audio: null,
|
||||
audioUrl: null, // blob URL ref for cleanup
|
||||
audioReady: false,
|
||||
audioLoadingPromise: null,
|
||||
midPreloadTriggered: false,
|
||||
transcription: (!isImageBlock && blockData) ? (blockData.transcription || []) : [],
|
||||
animFrameId: null,
|
||||
lastWordSpan: null,
|
||||
lastSentenceSpans: []
|
||||
});
|
||||
|
||||
globalBlockIndex++;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
|
||||
// Render words and run sync for every instance (text is cheap and already in memory)
|
||||
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();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Image Resolution Helpers
|
||||
// ============================================
|
||||
|
||||
function findEditorBlockForContent(block) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const eb of editorBlocks) {
|
||||
if (eb.content === block.content) return eb;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getBlockImages(block, blockData) {
|
||||
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;
|
||||
}
|
||||
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 [];
|
||||
}
|
||||
|
||||
function buildImageHtml(block, blockData) {
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (block.content) {
|
||||
const dataUriMatch = block.content.match(/!\[([^\]]*)\]\((data:image\/[^)]+)\)/);
|
||||
if (dataUriMatch) {
|
||||
return `<img src="${dataUriMatch[2]}" alt="${dataUriMatch[1] || 'Image'}">`;
|
||||
}
|
||||
}
|
||||
|
||||
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">`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
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>
|
||||
<div id="reader-btn-spinner" class="reader-btn-spinner" style="display:none;"></div>
|
||||
`;
|
||||
document.body.appendChild(btn);
|
||||
btn.addEventListener('click', handleReaderFloatingClick);
|
||||
|
||||
const container = document.getElementById('readerContainer');
|
||||
container.addEventListener('click', handleReaderWordClick);
|
||||
|
||||
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');
|
||||
if (!readerContainer || !btn) 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';
|
||||
}
|
||||
|
||||
function removeReaderUI() {
|
||||
const oldBtn = document.getElementById('reader-floating-btn');
|
||||
if (oldBtn) oldBtn.remove();
|
||||
readerStarted = false;
|
||||
currentReaderInstance = null;
|
||||
currentReaderIndex = -1;
|
||||
readerUICreated = false;
|
||||
|
||||
window.removeEventListener('resize', positionReaderUI);
|
||||
window.removeEventListener('scroll', positionReaderUI);
|
||||
|
||||
cleanupAllReaderInstances();
|
||||
}
|
||||
|
||||
function cleanupAllReaderInstances() {
|
||||
for (const inst of readerInstances) {
|
||||
if (inst.audio) {
|
||||
try { inst.audio.pause(); } catch (e) {}
|
||||
inst.audio = null;
|
||||
}
|
||||
if (inst.audioUrl) {
|
||||
try { URL.revokeObjectURL(inst.audioUrl); } catch (e) {}
|
||||
inst.audioUrl = null;
|
||||
}
|
||||
inst.audioReady = false;
|
||||
inst.audioLoadingPromise = null;
|
||||
if (inst.animFrameId) cancelAnimationFrame(inst.animFrameId);
|
||||
}
|
||||
}
|
||||
|
||||
function showReaderUI() {
|
||||
const btn = document.getElementById('reader-floating-btn');
|
||||
if (btn) btn.style.display = 'flex';
|
||||
positionReaderUI();
|
||||
}
|
||||
|
||||
function hideReaderUI() {
|
||||
const btn = document.getElementById('reader-floating-btn');
|
||||
if (btn) btn.style.display = 'none';
|
||||
}
|
||||
|
||||
function setReaderButtonLoading(isLoading) {
|
||||
const btn = document.getElementById('reader-floating-btn');
|
||||
if (!btn) return;
|
||||
btn.classList.toggle('loading', isLoading);
|
||||
const spinner = document.getElementById('reader-btn-spinner');
|
||||
if (spinner) spinner.style.display = isLoading ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Audio Lazy Loading + Memory Management
|
||||
// ============================================
|
||||
|
||||
function ensureReaderAudioLoaded(inst) {
|
||||
if (inst.audioReady && inst.audio) return Promise.resolve(inst);
|
||||
if (inst.audioLoadingPromise) return inst.audioLoadingPromise;
|
||||
|
||||
inst.audioLoadingPromise = new Promise((resolve, reject) => {
|
||||
const blockData = inst.blockData;
|
||||
if (!blockData || !blockData.audio_data) {
|
||||
inst.audioLoadingPromise = null;
|
||||
return reject(new Error('No audio data'));
|
||||
}
|
||||
|
||||
try {
|
||||
const audioBlob = base64ToBlob(blockData.audio_data, `audio/${blockData.audio_format || 'mp3'}`);
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
const audio = new Audio(audioUrl);
|
||||
|
||||
const onCanPlay = () => {
|
||||
audio.removeEventListener('error', onError);
|
||||
inst.audio = audio;
|
||||
inst.audioUrl = audioUrl;
|
||||
inst.audioReady = true;
|
||||
wireReaderAudioEvents(inst);
|
||||
resolve(inst);
|
||||
};
|
||||
const onError = () => {
|
||||
audio.removeEventListener('canplay', onCanPlay);
|
||||
try { URL.revokeObjectURL(audioUrl); } catch (e) {}
|
||||
inst.audioLoadingPromise = null;
|
||||
reject(new Error('Audio failed to load'));
|
||||
};
|
||||
audio.addEventListener('canplay', onCanPlay, { once: true });
|
||||
audio.addEventListener('error', onError, { once: true });
|
||||
|
||||
// Audio.load is implicit; setting src starts loading metadata
|
||||
audio.preload = 'auto';
|
||||
audio.load();
|
||||
} catch (err) {
|
||||
inst.audioLoadingPromise = null;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
return inst.audioLoadingPromise;
|
||||
}
|
||||
|
||||
function wireReaderAudioEvents(inst) {
|
||||
const audio = inst.audio;
|
||||
|
||||
audio.addEventListener('play', () => {
|
||||
startReaderHighlightLoop(inst);
|
||||
updateReaderButton('playing');
|
||||
});
|
||||
audio.addEventListener('pause', () => {
|
||||
stopReaderHighlightLoop(inst);
|
||||
updateReaderButton('paused');
|
||||
});
|
||||
audio.addEventListener('ended', () => {
|
||||
stopReaderHighlightLoop(inst);
|
||||
clearReaderHighlights(inst);
|
||||
const nextIdx = findNextAudioIndex(inst.index);
|
||||
if (nextIdx >= 0) {
|
||||
playReaderInstanceByIndex(nextIdx, { autoAdvance: true });
|
||||
} else {
|
||||
updateReaderButton('paused');
|
||||
currentReaderInstance = null;
|
||||
currentReaderIndex = -1;
|
||||
}
|
||||
});
|
||||
// Mid-play safety net: ensure next is ready by 70% of current
|
||||
audio.addEventListener('timeupdate', () => {
|
||||
if (inst.midPreloadTriggered) return;
|
||||
if (!audio.duration || isNaN(audio.duration)) return;
|
||||
if ((audio.currentTime / audio.duration) >= READER_MID_PRELOAD_THRESHOLD) {
|
||||
inst.midPreloadTriggered = true;
|
||||
const nextIdx = findNextAudioIndex(inst.index);
|
||||
if (nextIdx >= 0) {
|
||||
ensureReaderAudioLoaded(readerInstances[nextIdx]).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function preloadReaderAhead(fromIndex) {
|
||||
let preloadedCount = 0;
|
||||
let idx = fromIndex + 1;
|
||||
while (idx < readerInstances.length && preloadedCount < READER_PRELOAD_AHEAD) {
|
||||
const inst = readerInstances[idx];
|
||||
if (inst.hasAudio) {
|
||||
ensureReaderAudioLoaded(inst).catch(() => {});
|
||||
preloadedCount++;
|
||||
}
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
|
||||
function pruneReaderLoadedAudio(currentIndex) {
|
||||
const loaded = readerInstances.filter(i => i.audioReady && i.audio);
|
||||
if (loaded.length <= READER_MAX_AUDIO_LOADED) return;
|
||||
|
||||
const keepLow = currentIndex - READER_KEEP_BEHIND;
|
||||
const keepHigh = currentIndex + READER_PRELOAD_AHEAD;
|
||||
|
||||
const candidates = loaded
|
||||
.filter(inst => inst !== currentReaderInstance)
|
||||
.map(inst => ({
|
||||
inst,
|
||||
inWindow: inst.index >= keepLow && inst.index <= keepHigh,
|
||||
distance: Math.abs(inst.index - currentIndex)
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.inWindow !== b.inWindow) return a.inWindow ? 1 : -1;
|
||||
return b.distance - a.distance;
|
||||
});
|
||||
|
||||
let toEvict = loaded.length - READER_MAX_AUDIO_LOADED;
|
||||
for (const c of candidates) {
|
||||
if (toEvict <= 0) break;
|
||||
releaseReaderAudio(c.inst);
|
||||
toEvict--;
|
||||
}
|
||||
}
|
||||
|
||||
function releaseReaderAudio(inst) {
|
||||
if (!inst.audio) return;
|
||||
try { inst.audio.pause(); } catch (e) {}
|
||||
if (inst.audioUrl) {
|
||||
try { URL.revokeObjectURL(inst.audioUrl); } catch (e) {}
|
||||
inst.audioUrl = null;
|
||||
}
|
||||
inst.audio = null;
|
||||
inst.audioReady = false;
|
||||
inst.audioLoadingPromise = null;
|
||||
inst.midPreloadTriggered = false;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Playback & Navigation
|
||||
// ============================================
|
||||
|
||||
function handleReaderFloatingClick() {
|
||||
if (!readerStarted) {
|
||||
readerStarted = true;
|
||||
const firstIdx = findNextAudioIndex(-1);
|
||||
if (firstIdx >= 0) playReaderInstanceByIndex(firstIdx);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentReaderInstance && currentReaderInstance.audio) {
|
||||
if (currentReaderInstance.audio.paused) {
|
||||
currentReaderInstance.audio.play().catch(console.error);
|
||||
updateReaderButton('playing');
|
||||
} else {
|
||||
currentReaderInstance.audio.pause();
|
||||
updateReaderButton('paused');
|
||||
}
|
||||
} else {
|
||||
const firstIdx = findNextAudioIndex(-1);
|
||||
if (firstIdx >= 0) playReaderInstanceByIndex(firstIdx);
|
||||
}
|
||||
}
|
||||
|
||||
async 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;
|
||||
playReaderInstanceByIndex(readerIdx, { timestamp });
|
||||
}
|
||||
|
||||
function findNextAudioIndex(afterIndex) {
|
||||
for (let i = afterIndex + 1; i < readerInstances.length; i++) {
|
||||
if (readerInstances[i].hasAudio) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
async function playReaderInstanceByIndex(index, opts = {}) {
|
||||
if (index < 0 || index >= readerInstances.length) {
|
||||
updateReaderButton('paused');
|
||||
currentReaderInstance = null;
|
||||
currentReaderIndex = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
const inst = readerInstances[index];
|
||||
if (!inst.hasAudio) {
|
||||
// Skip non-audio blocks
|
||||
playReaderInstanceByIndex(findNextAudioIndex(index), opts);
|
||||
return;
|
||||
}
|
||||
|
||||
const isAutoAdvance = opts.autoAdvance === true;
|
||||
const timestamp = opts.timestamp != null ? opts.timestamp : 0;
|
||||
|
||||
if (currentReaderInstance && currentReaderInstance !== inst) {
|
||||
stopReaderInstance(currentReaderInstance);
|
||||
}
|
||||
|
||||
readerStarted = true;
|
||||
currentReaderIndex = index;
|
||||
currentReaderInstance = inst;
|
||||
inst.midPreloadTriggered = false;
|
||||
|
||||
const needsLoad = !inst.audioReady;
|
||||
if (needsLoad) setReaderButtonLoading(true);
|
||||
|
||||
try {
|
||||
await ensureReaderAudioLoaded(inst);
|
||||
|
||||
if (needsLoad) setReaderButtonLoading(false);
|
||||
|
||||
inst.audio.currentTime = timestamp;
|
||||
await inst.audio.play();
|
||||
updateReaderButton('playing');
|
||||
|
||||
// Block-level scroll ONLY for manual navigation
|
||||
if (!isAutoAdvance) {
|
||||
const blockEl = document.querySelector(`.reader-block[data-reader-index="${index}"]`);
|
||||
if (blockEl) {
|
||||
const rect = blockEl.getBoundingClientRect();
|
||||
if (rect.top < 0 || rect.top > window.innerHeight * 0.6) {
|
||||
blockEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
preloadReaderAhead(index);
|
||||
pruneReaderLoadedAudio(index);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Reader playback failed:', err);
|
||||
setReaderButtonLoading(false);
|
||||
updateReaderButton('paused');
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Failed to load audio. Tap again to retry.', 'warning');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopReaderInstance(inst) {
|
||||
if (inst.audio) {
|
||||
try {
|
||||
inst.audio.pause();
|
||||
inst.audio.currentTime = 0;
|
||||
} catch (e) {}
|
||||
}
|
||||
stopReaderHighlightLoop(inst);
|
||||
clearReaderHighlights(inst);
|
||||
}
|
||||
|
||||
function scrollToReaderBlock(index) {
|
||||
const blockEl = document.querySelector(`.reader-block[data-reader-index="${index}"]`);
|
||||
if (blockEl) {
|
||||
const headerOffset = 100;
|
||||
const elementPosition = blockEl.getBoundingClientRect().top;
|
||||
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
|
||||
window.scrollTo({ top: offsetPosition, behavior: 'smooth' });
|
||||
|
||||
blockEl.classList.add('highlight-section');
|
||||
setTimeout(() => blockEl.classList.remove('highlight-section'), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 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();
|
||||
// Relaxed threshold for smoother scroll
|
||||
if (rect.top < window.innerHeight * 0.2 || rect.bottom > window.innerHeight * 0.8) {
|
||||
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 loading, the spinner overrides icons
|
||||
if (btn.classList.contains('loading')) return;
|
||||
|
||||
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 = `
|
||||
@keyframes readerSpin { to { transform: rotate(360deg); } }
|
||||
|
||||
#readerContainer {
|
||||
flex: 1;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
min-height: 500px;
|
||||
padding: 24px 48px !important;
|
||||
position: relative;
|
||||
max-width: 900px !important;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
#readerContainer { padding: 16px 24px !important; }
|
||||
}
|
||||
|
||||
.reader-flow { margin-bottom: 48px; }
|
||||
.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-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, background 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-floating-btn.loading {
|
||||
background: linear-gradient(135deg, #6b7280, #9ca3af);
|
||||
cursor: wait;
|
||||
}
|
||||
#reader-floating-btn.loading #reader-btn-text,
|
||||
#reader-floating-btn.loading #reader-btn-play,
|
||||
#reader-floating-btn.loading #reader-btn-pause {
|
||||
display: none !important;
|
||||
}
|
||||
.reader-btn-spinner {
|
||||
width: 24px; height: 24px;
|
||||
border: 3px solid rgba(255,255,255,0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: readerSpin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#reader-floating-btn { top: auto; bottom: 20px; right: 20px !important; left: auto !important; }
|
||||
}
|
||||
|
||||
.highlight-section {
|
||||
animation: highlightPulse 2s ease-out;
|
||||
}
|
||||
@keyframes highlightPulse {
|
||||
0% { background-color: rgba(79, 70, 229, 0.15); border-left: 4px solid #4f46e5; border-radius: var(--border-radius-sm); }
|
||||
100% { background-color: transparent; border-left: 4px solid transparent; border-radius: var(--border-radius-sm); }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
1164
static/js/markdown-editor.js
Normal file
1164
static/js/markdown-editor.js
Normal file
File diff suppressed because it is too large
Load Diff
270
static/js/pdf-handler.js
Normal file
270
static/js/pdf-handler.js
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Document Handler Module (PDF, DOCX, DOC)
|
||||
* AUTHORITATIVE renderDocumentBlocks() — single source of truth
|
||||
* Section markers are data-driven via editorBlocks[].sectionStart
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
const panel = document.getElementById('audiobookMakerPanel');
|
||||
if (panel) panel.style.display = 'flex';
|
||||
|
||||
const sidebar = document.getElementById('documentOutlineSidebar');
|
||||
if (sidebar) sidebar.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';
|
||||
|
||||
const panel = document.getElementById('audiobookMakerPanel');
|
||||
if (panel) panel.style.display = 'flex';
|
||||
|
||||
const sidebar = document.getElementById('documentOutlineSidebar');
|
||||
if (sidebar) sidebar.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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AUTHORITATIVE renderDocumentBlocks()
|
||||
* This is the ONLY version of this function in the entire app.
|
||||
* Section dividers are rendered from editorBlocks[].sectionStart data.
|
||||
*/
|
||||
function renderDocumentBlocks(blocks) {
|
||||
const editor = document.getElementById('markdownEditor');
|
||||
if (!editor) return;
|
||||
|
||||
editor.innerHTML = '';
|
||||
editorBlocks = [];
|
||||
|
||||
const emptyMessage = document.getElementById('emptyEditorMessage');
|
||||
if (emptyMessage) {
|
||||
emptyMessage.style.display = 'none';
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Store section info in editorBlocks data (data-driven approach)
|
||||
if (block.is_section_start) {
|
||||
const blockData = editorBlocks.find(b => b.id === blockId);
|
||||
if (blockData) {
|
||||
blockData.sectionStart = true;
|
||||
blockData.sectionName = block.section_name || 'Section';
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Now render all section dividers from data
|
||||
renderAllSectionDividers();
|
||||
|
||||
repairAllNewBlockLines();
|
||||
|
||||
// Initialize panel state for new document
|
||||
const textBlocks = getTextBlocks();
|
||||
if (textBlocks.length > 0) {
|
||||
panelState.startingBlockId = textBlocks[0].id;
|
||||
panelState.blockCount = textBlocks.length; // Modified: set to total blocks
|
||||
}
|
||||
|
||||
updatePanelUI();
|
||||
renderDocumentOutline();
|
||||
checkEmptyEditor();
|
||||
}
|
||||
|
||||
// Keep backward compatibility alias
|
||||
function renderPdfBlocks(blocks) {
|
||||
renderDocumentBlocks(blocks);
|
||||
}
|
||||
Reference in New Issue
Block a user