You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
432 lines
13 KiB
432 lines
13 KiB
"""
|
|
Content Viewer - Center panel for displaying lesson content
|
|
"""
|
|
|
|
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel
|
|
from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEnginePage
|
|
from PyQt5.QtCore import Qt, pyqtSignal, QUrl
|
|
from pathlib import Path
|
|
import markdown
|
|
from pymdownx import superfences, arithmatex
|
|
|
|
from app import config
|
|
from app.models import Lesson
|
|
from app.utils import VariableWrapper
|
|
|
|
|
|
class ContentViewer(QWidget):
|
|
"""Center panel for displaying lesson content with markdown and MathJax"""
|
|
|
|
# Signals
|
|
scroll_position_changed = pyqtSignal(float) # For auto-save
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.current_lesson = None
|
|
self.markdown_converter = self._init_markdown()
|
|
self.variable_wrapper = VariableWrapper()
|
|
|
|
self.init_ui()
|
|
|
|
def init_ui(self):
|
|
"""Initialize the UI components"""
|
|
layout = QVBoxLayout(self)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
# Lesson title bar
|
|
self.title_label = QLabel("No lesson selected")
|
|
self.title_label.setStyleSheet(f"""
|
|
background-color: {config.COLOR_PRIMARY};
|
|
color: white;
|
|
font-size: 16pt;
|
|
font-weight: bold;
|
|
padding: 12px;
|
|
""")
|
|
self.title_label.setWordWrap(True)
|
|
layout.addWidget(self.title_label)
|
|
|
|
# Web view for content
|
|
self.web_view = QWebEngineView()
|
|
self.web_view.setPage(QWebEnginePage(self.web_view))
|
|
layout.addWidget(self.web_view, 1)
|
|
|
|
# Load welcome page
|
|
self.show_welcome()
|
|
|
|
def _init_markdown(self):
|
|
"""Initialize markdown converter with extensions"""
|
|
return markdown.Markdown(
|
|
extensions=[
|
|
'extra',
|
|
'codehilite',
|
|
'tables',
|
|
'toc',
|
|
'pymdownx.arithmatex',
|
|
'pymdownx.superfences',
|
|
'pymdownx.highlight',
|
|
'pymdownx.inlinehilite',
|
|
],
|
|
extension_configs={
|
|
'pymdownx.arithmatex': {
|
|
'generic': True
|
|
},
|
|
'codehilite': {
|
|
'css_class': 'highlight',
|
|
'linenums': False
|
|
}
|
|
}
|
|
)
|
|
|
|
def show_welcome(self):
|
|
"""Display welcome message"""
|
|
html = self._wrap_html("""
|
|
<div style="text-align: center; padding: 60px 20px;">
|
|
<h1>Welcome to Tesla Coil Spark Physics Course</h1>
|
|
<p style="font-size: 18px; color: #666;">
|
|
Select a lesson from the navigation panel to begin learning.
|
|
</p>
|
|
<p style="margin-top: 40px; color: #999;">
|
|
⚡ Explore the fascinating world of Tesla coils and electromagnetic theory ⚡
|
|
</p>
|
|
</div>
|
|
""", "Welcome")
|
|
self.web_view.setHtml(html)
|
|
self.title_label.setText("Welcome")
|
|
|
|
def load_lesson(self, lesson: Lesson):
|
|
"""Load and display a lesson"""
|
|
self.current_lesson = lesson
|
|
self.title_label.setText(f"{lesson.order}. {lesson.title}")
|
|
|
|
# Read markdown file
|
|
lesson_path = Path(lesson.file_path)
|
|
if not lesson_path.exists():
|
|
self.show_error(f"Lesson file not found: {lesson.file_path}")
|
|
return
|
|
|
|
try:
|
|
with open(lesson_path, 'r', encoding='utf-8') as f:
|
|
markdown_content = f.read()
|
|
|
|
# Convert markdown to HTML
|
|
html_content = self.markdown_converter.convert(markdown_content)
|
|
|
|
# Process custom tags
|
|
html_content = self._process_custom_tags(html_content, lesson)
|
|
|
|
# Wrap variables with tooltips
|
|
html_content = self.variable_wrapper.wrap_in_context(html_content)
|
|
|
|
# Wrap in full HTML document
|
|
full_html = self._wrap_html(html_content, lesson.title)
|
|
|
|
# Load into web view
|
|
self.web_view.setHtml(full_html, QUrl.fromLocalFile(str(lesson_path.parent)))
|
|
|
|
except Exception as e:
|
|
self.show_error(f"Error loading lesson: {str(e)}")
|
|
|
|
def _process_custom_tags(self, html: str, lesson: Lesson) -> str:
|
|
"""Process custom tags like {exercise:id} and {image:file}"""
|
|
import re
|
|
|
|
# Process {exercise:id} tags
|
|
def replace_exercise(match):
|
|
exercise_id = match.group(1)
|
|
return f'''
|
|
<div class="exercise-placeholder" data-exercise-id="{exercise_id}">
|
|
<h3>📝 Exercise: {exercise_id}</h3>
|
|
<p><em>Interactive exercise will be loaded here</em></p>
|
|
</div>
|
|
'''
|
|
html = re.sub(r'\{exercise:([^}]+)\}', replace_exercise, html)
|
|
|
|
# Process {image:file} tags
|
|
def replace_image(match):
|
|
image_file = match.group(1)
|
|
image_path = config.IMAGES_DIR / image_file
|
|
return f'<img src="{image_path}" alt="{image_file}" style="max-width: 100%; height: auto;" />'
|
|
html = re.sub(r'\{image:([^}]+)\}', replace_image, html)
|
|
|
|
return html
|
|
|
|
def _wrap_html(self, content: str, title: str) -> str:
|
|
"""Wrap content in full HTML document with styling and MathJax"""
|
|
return f"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>{title}</title>
|
|
<style>
|
|
body {{
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
line-height: 1.6;
|
|
color: {config.COLOR_TEXT};
|
|
max-width: 900px;
|
|
margin: 0 auto;
|
|
padding: 20px 40px;
|
|
background-color: white;
|
|
}}
|
|
|
|
h1, h2, h3, h4, h5, h6 {{
|
|
color: {config.COLOR_PRIMARY};
|
|
margin-top: 1.5em;
|
|
margin-bottom: 0.5em;
|
|
}}
|
|
|
|
h1 {{ font-size: 2.2em; border-bottom: 3px solid {config.COLOR_PRIMARY}; padding-bottom: 10px; }}
|
|
h2 {{ font-size: 1.8em; border-bottom: 2px solid {config.COLOR_SECONDARY}; padding-bottom: 8px; }}
|
|
h3 {{ font-size: 1.4em; }}
|
|
|
|
p {{ margin: 1em 0; }}
|
|
|
|
code {{
|
|
background-color: #f4f4f4;
|
|
padding: 2px 6px;
|
|
border-radius: 3px;
|
|
font-family: 'Consolas', 'Monaco', monospace;
|
|
font-size: 0.9em;
|
|
}}
|
|
|
|
pre {{
|
|
background-color: #f4f4f4;
|
|
padding: 15px;
|
|
border-radius: 5px;
|
|
overflow-x: auto;
|
|
border-left: 4px solid {config.COLOR_PRIMARY};
|
|
}}
|
|
|
|
pre code {{
|
|
background-color: transparent;
|
|
padding: 0;
|
|
}}
|
|
|
|
blockquote {{
|
|
border-left: 4px solid {config.COLOR_WARNING};
|
|
padding-left: 20px;
|
|
margin-left: 0;
|
|
color: #666;
|
|
font-style: italic;
|
|
}}
|
|
|
|
table {{
|
|
border-collapse: collapse;
|
|
width: 100%;
|
|
margin: 1.5em 0;
|
|
}}
|
|
|
|
th, td {{
|
|
border: 1px solid #ddd;
|
|
padding: 10px;
|
|
text-align: left;
|
|
}}
|
|
|
|
th {{
|
|
background-color: {config.COLOR_PRIMARY};
|
|
color: white;
|
|
font-weight: bold;
|
|
}}
|
|
|
|
tr:nth-child(even) {{
|
|
background-color: #f9f9f9;
|
|
}}
|
|
|
|
img {{
|
|
max-width: 100%;
|
|
height: auto;
|
|
display: block;
|
|
margin: 20px auto;
|
|
border-radius: 5px;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
}}
|
|
|
|
.exercise-placeholder {{
|
|
background-color: #fff8dc;
|
|
border: 2px dashed {config.COLOR_WARNING};
|
|
padding: 20px;
|
|
margin: 20px 0;
|
|
border-radius: 5px;
|
|
}}
|
|
|
|
.math {{
|
|
font-size: 1.1em;
|
|
}}
|
|
|
|
/* Syntax highlighting */
|
|
.highlight {{
|
|
background: #f4f4f4;
|
|
}}
|
|
|
|
/* Variable tooltip styles */
|
|
.var-tooltip {{
|
|
color: {config.COLOR_PRIMARY};
|
|
font-weight: 600;
|
|
cursor: help;
|
|
border-bottom: 1px dotted {config.COLOR_PRIMARY};
|
|
position: relative;
|
|
display: inline-block;
|
|
transition: all 0.2s ease;
|
|
}}
|
|
|
|
.var-tooltip:hover {{
|
|
color: {config.COLOR_SECONDARY};
|
|
border-bottom-color: {config.COLOR_SECONDARY};
|
|
}}
|
|
|
|
/* Tooltip popup */
|
|
.tooltip-popup {{
|
|
position: absolute;
|
|
background-color: #2c3e50;
|
|
color: white;
|
|
padding: 12px 16px;
|
|
border-radius: 6px;
|
|
font-size: 13px;
|
|
font-weight: normal;
|
|
max-width: 350px;
|
|
z-index: 10000;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
|
line-height: 1.6;
|
|
white-space: pre-wrap;
|
|
pointer-events: none;
|
|
opacity: 0;
|
|
transition: opacity 0.2s ease;
|
|
}}
|
|
|
|
.tooltip-popup.show {{
|
|
opacity: 1;
|
|
}}
|
|
|
|
.tooltip-arrow {{
|
|
position: absolute;
|
|
width: 0;
|
|
height: 0;
|
|
border-left: 8px solid transparent;
|
|
border-right: 8px solid transparent;
|
|
border-top: 8px solid #2c3e50;
|
|
bottom: -8px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
}}
|
|
</style>
|
|
|
|
<!-- MathJax for equation rendering -->
|
|
<script>
|
|
MathJax = {{
|
|
tex: {{
|
|
inlineMath: [['$', '$'], ['\\\\(', '\\\\)']],
|
|
displayMath: [['$$', '$$'], ['\\\\[', '\\\\]']],
|
|
processEscapes: true
|
|
}},
|
|
svg: {{
|
|
fontCache: 'global'
|
|
}}
|
|
}};
|
|
</script>
|
|
<script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js" async></script>
|
|
|
|
<!-- Variable tooltip JavaScript -->
|
|
<script>
|
|
// Create tooltip element
|
|
let tooltipPopup = null;
|
|
|
|
function createTooltip() {{
|
|
if (!tooltipPopup) {{
|
|
tooltipPopup = document.createElement('div');
|
|
tooltipPopup.className = 'tooltip-popup';
|
|
|
|
const arrow = document.createElement('div');
|
|
arrow.className = 'tooltip-arrow';
|
|
tooltipPopup.appendChild(arrow);
|
|
|
|
const content = document.createElement('div');
|
|
content.className = 'tooltip-content';
|
|
tooltipPopup.appendChild(content);
|
|
|
|
document.body.appendChild(tooltipPopup);
|
|
}}
|
|
}}
|
|
|
|
function showTooltip(element, text) {{
|
|
createTooltip();
|
|
|
|
// Set content
|
|
const content = tooltipPopup.querySelector('.tooltip-content');
|
|
content.textContent = text.replace(/ /g, '\\n');
|
|
|
|
// Position tooltip
|
|
const rect = element.getBoundingClientRect();
|
|
const tooltipRect = tooltipPopup.getBoundingClientRect();
|
|
|
|
const left = rect.left + (rect.width / 2) - (tooltipPopup.offsetWidth / 2);
|
|
const top = rect.top - tooltipPopup.offsetHeight - 10;
|
|
|
|
tooltipPopup.style.left = Math.max(10, left) + 'px';
|
|
tooltipPopup.style.top = Math.max(10, top) + window.scrollY + 'px';
|
|
|
|
// Show tooltip
|
|
setTimeout(() => {{
|
|
tooltipPopup.classList.add('show');
|
|
}}, 10);
|
|
}}
|
|
|
|
function hideTooltip() {{
|
|
if (tooltipPopup) {{
|
|
tooltipPopup.classList.remove('show');
|
|
}}
|
|
}}
|
|
|
|
// Attach event listeners after DOM loads
|
|
document.addEventListener('DOMContentLoaded', function() {{
|
|
const varTooltips = document.querySelectorAll('.var-tooltip');
|
|
|
|
varTooltips.forEach(element => {{
|
|
element.addEventListener('mouseenter', function() {{
|
|
const tooltipText = this.getAttribute('title');
|
|
if (tooltipText) {{
|
|
showTooltip(this, tooltipText);
|
|
// Remove title to prevent browser default tooltip
|
|
this.setAttribute('data-original-title', tooltipText);
|
|
this.removeAttribute('title');
|
|
}}
|
|
}});
|
|
|
|
element.addEventListener('mouseleave', function() {{
|
|
hideTooltip();
|
|
// Restore title
|
|
const originalTitle = this.getAttribute('data-original-title');
|
|
if (originalTitle) {{
|
|
this.setAttribute('title', originalTitle);
|
|
}}
|
|
}});
|
|
}});
|
|
}});
|
|
</script>
|
|
</head>
|
|
<body>
|
|
{content}
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
def show_error(self, message: str):
|
|
"""Display an error message"""
|
|
html = self._wrap_html(f"""
|
|
<div style="text-align: center; padding: 60px 20px; color: {config.COLOR_ERROR};">
|
|
<h1>⚠ Error</h1>
|
|
<p style="font-size: 18px;">{message}</p>
|
|
</div>
|
|
""", "Error")
|
|
self.web_view.setHtml(html)
|
|
|
|
def get_scroll_position(self) -> float:
|
|
"""Get current scroll position (0.0 to 1.0)"""
|
|
# This would require JavaScript execution in QWebEngineView
|
|
# For now, return 0.0 - can be implemented later
|
|
return 0.0
|
|
|
|
def set_scroll_position(self, position: float):
|
|
"""Set scroll position (0.0 to 1.0)"""
|
|
# This would require JavaScript execution in QWebEngineView
|
|
# For now, do nothing - can be implemented later
|
|
pass
|