From e1dc07bd93ace5ee5328ed1bdc97732e77a74581 Mon Sep 17 00:00:00 2001 From: elliot Date: Wed, 25 Feb 2026 13:56:38 -0500 Subject: [PATCH 1/2] with Claude, merge `new-react-renderer-stuff` into main --- .../plans/2026-02-25-merge-react-renderer.md | 34 + crates/pampa/src/wasm_entry_points/mod.rs | 34 +- crates/quarto-core/.DS_Store | Bin 0 -> 6148 bytes crates/quarto-core/src/pipeline.rs | 142 ++- crates/quarto-core/src/render_to_file.rs | 6 +- crates/quarto-test/src/runner.rs | 4 +- crates/quarto/src/commands/render.rs | 4 +- crates/quarto/tests/smoke_all.rs | 2 +- crates/wasm-quarto-hub-client/Cargo.lock | 1 - crates/wasm-quarto-hub-client/src/lib.rs | 156 +++ .../src/components/AspectRatioScaler.tsx | 92 ++ hub-client/src/components/Editor.css | 43 +- hub-client/src/components/Editor.tsx | 280 ++++-- hub-client/src/components/MinimalHeader.css | 24 + hub-client/src/components/MinimalHeader.tsx | 9 + hub-client/src/components/OutlinePanel.css | 42 +- hub-client/src/components/OutlinePanel.tsx | 24 + .../src/components/ReactAstRenderer.tsx | 337 +++++++ .../src/components/ReactAstSlideRenderer.tsx | 944 ++++++++++++++++++ hub-client/src/components/ReactPreview.tsx | 282 ++++++ hub-client/src/components/ReactRenderer.tsx | 51 + .../src/components/tabs/SettingsTab.tsx | 67 ++ hub-client/src/hooks/useCursorToSlide.ts | 95 ++ hub-client/src/hooks/useSectionThumbnails.ts | 134 +++ hub-client/src/hooks/useSlideThumbnails.tsx | 176 ++++ hub-client/src/index.css | 10 +- hub-client/src/services/wasmRenderer.ts | 217 +++- package-lock.json | 158 ++- package.json | 3 + 29 files changed, 3164 insertions(+), 207 deletions(-) create mode 100644 claude-notes/plans/2026-02-25-merge-react-renderer.md create mode 100644 crates/quarto-core/.DS_Store create mode 100644 hub-client/src/components/AspectRatioScaler.tsx create mode 100644 hub-client/src/components/ReactAstRenderer.tsx create mode 100644 hub-client/src/components/ReactAstSlideRenderer.tsx create mode 100644 hub-client/src/components/ReactPreview.tsx create mode 100644 hub-client/src/components/ReactRenderer.tsx create mode 100644 hub-client/src/hooks/useCursorToSlide.ts create mode 100644 hub-client/src/hooks/useSectionThumbnails.ts create mode 100644 hub-client/src/hooks/useSlideThumbnails.tsx diff --git a/claude-notes/plans/2026-02-25-merge-react-renderer.md b/claude-notes/plans/2026-02-25-merge-react-renderer.md new file mode 100644 index 00000000..e9f8529d --- /dev/null +++ b/claude-notes/plans/2026-02-25-merge-react-renderer.md @@ -0,0 +1,34 @@ +# Merge new-react-renderer-stuff into main + +## Overview +Merge the React renderer and slide functionality from `new-react-renderer-stuff` branch into `main`, preserving both old and new functionality where conflicts exist. + +## Branch Comparison +- 15 commits ahead in new-react-renderer-stuff +- Main changes: React rendering pipeline, slides support, thumbnails, cursor sync +- 7 new files (mostly React components/hooks) +- 11 modified files (Rust WASM, React components, styles) + +## Work Items + +### Phase 1: Analysis +- [ ] Review modified Rust files for conflicts +- [ ] Review modified TypeScript/React files for conflicts +- [ ] Identify areas needing feature flags + +### Phase 2: Merge Strategy +- [ ] Attempt git merge to see automatic conflict resolution +- [ ] Handle conflicts in Rust files (pipeline.rs, WASM entry points) +- [ ] Handle conflicts in React files (Editor.tsx, etc.) +- [ ] Add feature flags where old/new functionality differs + +### Phase 3: Testing & Validation +- [ ] Build Rust workspace (`cargo build --workspace`) +- [ ] Build WASM module +- [ ] Build hub-client +- [ ] Test basic functionality +- [ ] Verify both rendering paths work + +### Phase 4: Cleanup +- [ ] Update changelog if needed +- [ ] Commit merge results diff --git a/crates/pampa/src/wasm_entry_points/mod.rs b/crates/pampa/src/wasm_entry_points/mod.rs index e8361626..b8e99191 100644 --- a/crates/pampa/src/wasm_entry_points/mod.rs +++ b/crates/pampa/src/wasm_entry_points/mod.rs @@ -55,8 +55,38 @@ pub fn qmd_to_pandoc( } pub fn parse_qmd(input: &[u8], include_resolved_locations: bool) -> String { - let (pandoc, context) = qmd_to_pandoc(input).unwrap(); - pandoc_to_json(&pandoc, &context, include_resolved_locations).unwrap() + match qmd_to_pandoc(input) { + Ok((pandoc, context)) => { + match pandoc_to_json(&pandoc, &context, include_resolved_locations) { + Ok(json) => { + // Return success response with AST + serde_json::json!({ + "success": true, + "ast": json + }) + .to_string() + } + Err(e) => { + // JSON serialization error + serde_json::json!({ + "success": false, + "error": e, + "diagnostics": [] + }) + .to_string() + } + } + } + Err(diagnostic_strings) => { + // Parse errors with diagnostics + serde_json::json!({ + "success": false, + "error": "Failed to parse QMD content", + "diagnostics": diagnostic_strings.iter().map(|d| serde_json::json!({"message": d})).collect::>() + }) + .to_string() + } + } } /// Render a parsed document using a template bundle. diff --git a/crates/quarto-core/.DS_Store b/crates/quarto-core/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..c8f57c0f41fa40463ed458d3225491b461641bc7 GIT binary patch literal 6148 zcmeHK%}T>S5Z-O8O({YS3Oz1(E!f&p5HBIt7cim+m718M!I&*cY7V84v%Zi|;`2DO zy8(+ii`W_1{pNQ!`$6`HF~;3xIAqLbj9JhSIVv@R?%GhpBqMShBU=Qq48Zyb7AE%B z0l&S;GM2K3p!oj%ag^nQ!6$DtTRXdLt8I0yJMT#rUhe11%=Ob7v@WHLgG%>2sM&seSFIFe0k4KaB zx?>$29-Usyp5vE9zG*r+u&rdzUnAN;`X%fri5j<6Pl|@Jl5Cg;jF|geXm~+AE zZZ{3IdSZYWs9^y22LTPyF<5F;TL*M_ea3hT5e0O7OCSn^j=@qRL_oMM1=OY7JTbT~ z2fr|Rj=@r+E@xcL4C9!YtH%phvx8r#bjBTx)Dr{5z$ODtZQ6MLpTjRx`^eucA&VFw z2L2fXyfyWvE-cENtv{BBXRUyC4-EzLN>o5V-?#*Tf%`~L1$A7Y4tb8jQX`InepL=g O7Xd{Gb;Q6gFz^L>MM-@C literal 0 HcmV?d00001 diff --git a/crates/quarto-core/src/pipeline.rs b/crates/quarto-core/src/pipeline.rs index 12a2ff77..6583ff9c 100644 --- a/crates/quarto-core/src/pipeline.rs +++ b/crates/quarto-core/src/pipeline.rs @@ -48,6 +48,7 @@ use std::sync::Arc; use quarto_doctemplate::Template; use quarto_error_reporting::DiagnosticMessage; +use quarto_pandoc_types::Pandoc; use quarto_source_map::SourceContext; use crate::Result; @@ -110,6 +111,15 @@ pub struct RenderOutput { pub source_context: SourceContext, } +pub struct AstOutput { + /// The AST serialized as JSON. + pub ast: Pandoc, + /// Non-fatal warnings collected during rendering. + pub warnings: Vec, + /// Source context for mapping locations in diagnostics. + pub source_context: SourceContext, +} + /// Build the standard HTML pipeline stages. /// /// Returns the stages as a vector, allowing callers to customize before @@ -219,6 +229,89 @@ pub fn build_html_pipeline_with_stages( Pipeline::new(stages) } +pub async fn run_pipeline( + content: &[u8], + source_name: &str, + ctx: &mut RenderContext<'_>, + runtime: Arc, + stages: Vec>, +) -> Result<(PipelineData, Vec)> { + // Create StageContext from RenderContext data + let mut stage_ctx = StageContext::new( + runtime, + ctx.format.clone(), + ctx.project.clone(), + ctx.document.clone(), + ) + .map_err(|e| crate::error::QuartoError::Other(e.to_string()))?; + + // Transfer artifacts from RenderContext to StageContext + stage_ctx.artifacts = std::mem::take(&mut ctx.artifacts); + + // Create input from content + let input = PipelineData::LoadedSource(LoadedSource::new( + PathBuf::from(source_name), + content.to_vec(), + )); + + let pipeline = Pipeline::new(stages).expect("Pipeline stages should be compatible"); + + let result = pipeline.run(input, &mut stage_ctx).await; + + // Transfer artifacts back to RenderContext + ctx.artifacts = stage_ctx.artifacts; + + result + .map_err(|e| match e { + crate::stage::PipelineError::StageError { diagnostics, .. } + if !diagnostics.is_empty() => + { + // Create a SourceContext for the parse error + let mut source_context = SourceContext::new(); + let content_str = String::from_utf8_lossy(content).to_string(); + source_context.add_file(source_name.to_string(), Some(content_str)); + crate::error::QuartoError::Parse(crate::error::ParseError::new( + diagnostics, + source_context, + )) + } + other => crate::error::QuartoError::Other(other.to_string()), + }) + .map(|d| (d, stage_ctx.diagnostics)) +} + +pub async fn parse_qmd_to_ast( + content: &[u8], + source_name: &str, + ctx: &mut RenderContext<'_>, + runtime: Arc, +) -> Result { + // Build pipeline based on config + // If custom CSS or template is specified, use a customized ApplyTemplateStage + let stages: Vec> = vec![ + Box::new(ParseDocumentStage::new()), + Box::new(EngineExecutionStage::new()), + Box::new(AstTransformsStage::new()), + ]; + + let (output, warnings) = run_pipeline(content, source_name, ctx, runtime, stages).await?; + // Extract the rendered output + let ast = output.into_document_ast().ok_or_else(|| { + crate::error::QuartoError::Other("Pipeline did not produce ast".to_string()) + })?; + + // Create source context for the output + let mut source_context = SourceContext::new(); + let content_str = String::from_utf8_lossy(content).to_string(); + source_context.add_file(source_name.to_string(), Some(content_str)); + + Ok(AstOutput { + ast: ast.ast, + warnings, + source_context, + }) +} + /// Render QMD content to HTML. /// /// This is the unified async render pipeline used by both CLI and WASM. It: @@ -263,27 +356,9 @@ pub async fn render_qmd_to_html( config: &HtmlRenderConfig<'_>, runtime: Arc, ) -> Result { - // Create StageContext from RenderContext data - let mut stage_ctx = StageContext::new( - runtime, - ctx.format.clone(), - ctx.project.clone(), - ctx.document.clone(), - ) - .map_err(|e| crate::error::QuartoError::Other(e.to_string()))?; - - // Transfer artifacts from RenderContext to StageContext - stage_ctx.artifacts = std::mem::take(&mut ctx.artifacts); - - // Create input from content - let input = PipelineData::LoadedSource(LoadedSource::new( - PathBuf::from(source_name), - content.to_vec(), - )); - // Build pipeline based on config // If custom CSS or template is specified, use a customized ApplyTemplateStage - let pipeline = if config.template.is_some() || !config.css_paths.is_empty() { + let stages = if config.template.is_some() || !config.css_paths.is_empty() { let apply_config = ApplyTemplateConfig::new().with_css_paths(config.css_paths.to_vec()); // If custom template is provided, we'd need to pass it too // For now, css_paths is the main customization needed @@ -295,40 +370,17 @@ pub async fn render_qmd_to_html( Box::new(RenderHtmlBodyStage::new()), Box::new(ApplyTemplateStage::with_config(apply_config)), ]; - Pipeline::new(stages).expect("HTML pipeline stages should be compatible") + stages } else { - build_html_pipeline() + build_html_pipeline_stages() }; - // Run the async pipeline - let result = pipeline.run(input, &mut stage_ctx).await; - - // Transfer artifacts back to RenderContext - ctx.artifacts = stage_ctx.artifacts; - - // Handle result - let output = result.map_err(|e| match e { - crate::stage::PipelineError::StageError { diagnostics, .. } if !diagnostics.is_empty() => { - // Create a SourceContext for the parse error - let mut source_context = SourceContext::new(); - let content_str = String::from_utf8_lossy(content).to_string(); - source_context.add_file(source_name.to_string(), Some(content_str)); - crate::error::QuartoError::Parse(crate::error::ParseError::new( - diagnostics, - source_context, - )) - } - other => crate::error::QuartoError::Other(other.to_string()), - })?; - + let (output, diagnostics) = run_pipeline(content, source_name, ctx, runtime, stages).await?; // Extract the rendered output let rendered = output.into_rendered_output().ok_or_else(|| { crate::error::QuartoError::Other("Pipeline did not produce RenderedOutput".to_string()) })?; - // Collect diagnostics from the pipeline - let diagnostics = stage_ctx.diagnostics; - // Create source context for the output let mut source_context = SourceContext::new(); let content_str = String::from_utf8_lossy(content).to_string(); diff --git a/crates/quarto-core/src/render_to_file.rs b/crates/quarto-core/src/render_to_file.rs index 36b67b48..04667115 100644 --- a/crates/quarto-core/src/render_to_file.rs +++ b/crates/quarto-core/src/render_to_file.rs @@ -66,13 +66,13 @@ use tracing::{debug, warn}; use quarto_system_runtime::SystemRuntime; +use crate::Result; use crate::error::QuartoError; -use crate::format::{extract_format_metadata, Format}; -use crate::pipeline::{render_qmd_to_html, HtmlRenderConfig, RenderOutput}; +use crate::format::{Format, extract_format_metadata}; +use crate::pipeline::{HtmlRenderConfig, RenderOutput, render_qmd_to_html}; use crate::project::{DocumentInfo, ProjectContext}; use crate::render::{BinaryDependencies, RenderContext}; use crate::resources::{self, HtmlResourcePaths}; -use crate::Result; /// Options for rendering a document to a file. #[derive(Debug, Clone, Default)] diff --git a/crates/quarto-test/src/runner.rs b/crates/quarto-test/src/runner.rs index a987ba18..8a8cdd1d 100644 --- a/crates/quarto-test/src/runner.rs +++ b/crates/quarto-test/src/runner.rs @@ -14,7 +14,7 @@ use anyhow::{Context, Result}; use serde_yaml::Value; use crate::assertions::{LogLevel, LogMessage, VerifyContext}; -use crate::spec::{parse_test_specs, TestSpec}; +use crate::spec::{TestSpec, parse_test_specs}; /// Result of running tests on a single file. #[derive(Debug)] @@ -216,7 +216,7 @@ fn run_format_tests(input_path: &Path, spec: &TestSpec) -> Result RenderOutput { use std::sync::Arc; - use quarto_core::render_to_file::{render_to_file, RenderToFileOptions}; + use quarto_core::render_to_file::{RenderToFileOptions, render_to_file}; use quarto_system_runtime::NativeRuntime; let mut messages = Vec::new(); diff --git a/crates/quarto/src/commands/render.rs b/crates/quarto/src/commands/render.rs index 9f934852..9da0dbca 100644 --- a/crates/quarto/src/commands/render.rs +++ b/crates/quarto/src/commands/render.rs @@ -21,8 +21,8 @@ use anyhow::{Context, Result}; use tracing::info; use quarto_core::{ - render_document_to_file, Format, FormatIdentifier, ProjectContext, QuartoError, - RenderToFileOptions, + Format, FormatIdentifier, ProjectContext, QuartoError, RenderToFileOptions, + render_document_to_file, }; use quarto_system_runtime::{NativeRuntime, SystemRuntime}; diff --git a/crates/quarto/tests/smoke_all.rs b/crates/quarto/tests/smoke_all.rs index d03e0795..ddc07780 100644 --- a/crates/quarto/tests/smoke_all.rs +++ b/crates/quarto/tests/smoke_all.rs @@ -10,7 +10,7 @@ use std::path::Path; -use quarto_test::{run_test_file, TestResult}; +use quarto_test::{TestResult, run_test_file}; use walkdir::WalkDir; /// Run all smoke-all tests by discovering .qmd files in the smoke-all directory. diff --git a/crates/wasm-quarto-hub-client/Cargo.lock b/crates/wasm-quarto-hub-client/Cargo.lock index b58a0734..00a1fca9 100644 --- a/crates/wasm-quarto-hub-client/Cargo.lock +++ b/crates/wasm-quarto-hub-client/Cargo.lock @@ -1730,7 +1730,6 @@ dependencies = [ "quarto-source-map", "quarto-system-runtime", "quarto-util", - "quarto-yaml", "regex", "runtimelib", "serde", diff --git a/crates/wasm-quarto-hub-client/src/lib.rs b/crates/wasm-quarto-hub-client/src/lib.rs index 3661909b..17119471 100644 --- a/crates/wasm-quarto-hub-client/src/lib.rs +++ b/crates/wasm-quarto-hub-client/src/lib.rs @@ -430,6 +430,148 @@ fn create_wasm_project_context(path: &Path) -> ProjectContext { } } +/// Parse QMD content to Pandoc AST JSON using the unified pipeline. +/// +/// This function uses the same pipeline infrastructure as `render_qmd_to_html`, +/// ensuring feature parity. It runs through: +/// 1. ParseDocumentStage - Parse QMD to Pandoc AST +/// 2. EngineExecutionStage - Execute code cells (passes through in WASM) +/// 3. AstTransformsStage - Apply Quarto transforms (callouts, metadata, etc.) +/// +/// # Arguments +/// * `content` - QMD source text +/// +/// # Returns +/// JSON string containing: +/// - `success`: true/false +/// - `ast`: Serialized Pandoc AST (on success) +/// - `error`: Error message (on failure) +/// - `diagnostics`: Structured error diagnostics with line/column info +/// - `warnings`: Structured warning diagnostics with line/column info +#[wasm_bindgen] +pub async fn parse_qmd_to_ast(content: &str) -> String { + // Create a virtual path for this content + let path = Path::new("/input.qmd"); + + // Create project context + let project = create_wasm_project_context(path); + let doc = DocumentInfo::from_path(path); + let binaries = BinaryDependencies::new(); + + // Extract format metadata from frontmatter + let format_metadata = extract_format_metadata(content, "html").unwrap_or_default(); + let format = Format::html().with_metadata(format_metadata); + + let options = RenderOptions { + verbose: false, + execute: false, + use_freeze: false, + output_path: None, + }; + + let mut ctx = RenderContext::new(&project, &doc, &format, &binaries).with_options(options); + + // Create Arc runtime for the async pipeline + let runtime_arc: Arc = Arc::new(WasmRuntime::new()); + + let result = quarto_core::pipeline::parse_qmd_to_ast( + content.as_bytes(), + "/input.qmd", + &mut ctx, + runtime_arc, + ) + .await; + + match result { + Ok(output) => { + // Create an ASTContext from the SourceContext returned by the pipeline + // This is needed for pampa's JSON writer which tracks source locations + let ast_context = pampa::pandoc::ASTContext { + filenames: vec!["/input.qmd".to_string()], + example_list_counter: std::cell::Cell::new(1), + source_context: output.source_context.clone(), + parent_source_info: None, + }; + + // Serialize the AST to JSON using pampa's writer + let mut buf = Vec::new(); + let json_config = pampa::writers::json::JsonConfig { + include_inline_locations: true, + }; + + let ast_json = match pampa::writers::json::write_with_config( + &output.ast, + &ast_context, + &mut buf, + &json_config, + ) { + Ok(_) => match String::from_utf8(buf) { + Ok(json) => json, + Err(e) => { + return serde_json::to_string(&AstResponse { + success: false, + error: Some(format!("Failed to convert AST JSON to string: {}", e)), + ast: None, + qmd: None, + diagnostics: None, + warnings: None, + }) + .unwrap(); + } + }, + Err(e) => { + return serde_json::to_string(&AstResponse { + success: false, + error: Some(format!("Failed to serialize AST: {:?}", e)), + ast: None, + qmd: None, + diagnostics: None, + warnings: None, + }) + .unwrap(); + } + }; + + // Convert warnings to structured JSON with line/column info + let warnings = diagnostics_to_json(&output.warnings, &output.source_context); + serde_json::to_string(&AstResponse { + success: true, + error: None, + ast: Some(ast_json), + qmd: None, + diagnostics: None, + warnings: if warnings.is_empty() { + None + } else { + Some(warnings) + }, + }) + .unwrap() + } + Err(e) => { + // Extract structured diagnostics from parse errors + let (error_msg, diagnostics) = match &e { + QuartoError::Parse(parse_error) => { + let diags = + diagnostics_to_json(&parse_error.diagnostics, &parse_error.source_context); + (e.to_string(), Some(diags)) + } + _ => (e.to_string(), None), + }; + + serde_json::to_string(&AstResponse { + success: false, + error: Some(error_msg), + ast: None, + qmd: None, + diagnostics, + warnings: None, + }) + .unwrap() + } + } +} + /// Render a QMD file from the virtual filesystem. /// /// # Arguments @@ -2027,8 +2169,12 @@ struct AstResponse { qmd: Option, #[serde(skip_serializing_if = "Option::is_none")] error: Option, + /// Structured diagnostics (errors) with line/column information for Monaco. #[serde(skip_serializing_if = "Option::is_none")] diagnostics: Option>, + /// Structured warnings with line/column information for Monaco. + #[serde(skip_serializing_if = "Option::is_none")] + warnings: Option>, } /// Parse QMD content and return the Pandoc JSON AST. @@ -2064,6 +2210,7 @@ pub fn parse_qmd_content(content: &str) -> String { qmd: None, error: None, diagnostics: None, + warnings: None, }) .unwrap() } @@ -2075,6 +2222,7 @@ pub fn parse_qmd_content(content: &str) -> String { qmd: None, error: Some("Failed to serialize AST to JSON".to_string()), diagnostics: Some(diagnostics), + warnings: None, }) .unwrap() } @@ -2089,6 +2237,7 @@ pub fn parse_qmd_content(content: &str) -> String { qmd: None, error: Some(error_msg), diagnostics: None, + warnings: None, }) .unwrap() } @@ -2123,6 +2272,7 @@ pub fn ast_to_qmd(ast_json: &str) -> String { qmd: Some(qmd_text), error: None, diagnostics: None, + warnings: None, }) .unwrap() } @@ -2138,6 +2288,7 @@ pub fn ast_to_qmd(ast_json: &str) -> String { qmd: None, error: Some(format!("Failed to write QMD: {}", error_msg)), diagnostics: None, + warnings: None, }) .unwrap() } @@ -2149,6 +2300,7 @@ pub fn ast_to_qmd(ast_json: &str) -> String { qmd: None, error: Some(format!("Failed to parse JSON AST: {}", e)), diagnostics: None, + warnings: None, }) .unwrap(), } @@ -2186,6 +2338,7 @@ pub fn incremental_write_qmd(original_qmd: &str, new_ast_json: &str) -> String { qmd: None, error: Some(format!("Failed to parse original QMD: {}", error_msg)), diagnostics: None, + warnings: None, }) .unwrap(); } @@ -2202,6 +2355,7 @@ pub fn incremental_write_qmd(original_qmd: &str, new_ast_json: &str) -> String { qmd: None, error: Some(format!("Failed to parse new AST JSON: {}", e)), diagnostics: None, + warnings: None, }) .unwrap(); } @@ -2218,6 +2372,7 @@ pub fn incremental_write_qmd(original_qmd: &str, new_ast_json: &str) -> String { qmd: Some(result_qmd), error: None, diagnostics: None, + warnings: None, }) .unwrap(), Err(diags) => { @@ -2232,6 +2387,7 @@ pub fn incremental_write_qmd(original_qmd: &str, new_ast_json: &str) -> String { qmd: None, error: Some(format!("Incremental write failed: {}", error_msg)), diagnostics: None, + warnings: None, }) .unwrap() } diff --git a/hub-client/src/components/AspectRatioScaler.tsx b/hub-client/src/components/AspectRatioScaler.tsx new file mode 100644 index 00000000..ca249049 --- /dev/null +++ b/hub-client/src/components/AspectRatioScaler.tsx @@ -0,0 +1,92 @@ +import React, { useState, useEffect, useRef } from 'react'; + +interface AspectRatioScalerProps { + /** Virtual width of the content */ + width: number; + /** Virtual height of the content */ + height: number; + /** Content to render at the virtual dimensions */ + children: React.ReactNode; + /** Optional background color for the container */ + backgroundColor?: string; +} + +/** + * Component that maintains a fixed aspect ratio and scales its children + * to fit within the parent container while preserving the aspect ratio. + * + * The children are rendered as if they're in a container of size width x height, + * but the component scales and centers them to fit the actual parent. + */ +export function AspectRatioScaler({ + width, + height, + children, + backgroundColor = 'transparent' +}: AspectRatioScalerProps) { + const [scale, setScale] = useState(1); + const containerRef = useRef(null); + + useEffect(() => { + const updateScale = () => { + if (!containerRef.current) return; + + const containerWidth = containerRef.current.clientWidth; + const containerHeight = containerRef.current.clientHeight; + + // Don't calculate scale if container has no dimensions yet + if (containerWidth === 0 || containerHeight === 0) return; + + // Calculate scale to fit both dimensions while maintaining aspect ratio + const scaleX = containerWidth / width; + const scaleY = containerHeight / height; + const newScale = Math.min(scaleX, scaleY) * 0.95; // 95% to add some margin + + setScale(newScale); + }; + + updateScale(); + window.addEventListener('resize', updateScale); + + const resizeObserver = new ResizeObserver(updateScale); + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } + + return () => { + window.removeEventListener('resize', updateScale); + resizeObserver.disconnect(); + }; + }, [width, height]); + + return ( +
+
+ {children} +
+
+ ); +} diff --git a/hub-client/src/components/Editor.css b/hub-client/src/components/Editor.css index eab76873..c09266d5 100644 --- a/hub-client/src/components/Editor.css +++ b/hub-client/src/components/Editor.css @@ -279,7 +279,48 @@ .preview-pane { background: #fff; position: relative; - overflow: hidden; + overflow: scroll; +} + +.preview-pane.fullscreen { + flex: 1; + width: 100%; +} + +.fullscreen-close-btn { + position: fixed; + top: 20px; + right: 35px; + width: 50px; + height: 50px; + min-width: 50px; + min-height: 50px; + padding: 0; + background: rgba(0, 0, 0, 0.4); + border: 2px solid rgba(255, 255, 255, 0.6); + border-radius: 50%; + color: #fff; + font-size: 28px; + font-weight: 300; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); + backdrop-filter: blur(8px); +} + +.fullscreen-close-btn:hover { + background: rgba(0, 0, 0, 0.6); + border-color: rgba(255, 255, 255, 0.9); + transform: scale(1.25); +} + +.fullscreen-close-btn:active { + transform: scale(0.9); + transition: all 0.1s; } .preview-pane iframe { diff --git a/hub-client/src/components/Editor.tsx b/hub-client/src/components/Editor.tsx index 2d663d52..40858599 100644 --- a/hub-client/src/components/Editor.tsx +++ b/hub-client/src/components/Editor.tsx @@ -18,9 +18,10 @@ import { processFileForUpload } from '../services/resourceService'; import { usePresence } from '../hooks/usePresence'; import { usePreference } from '../hooks/usePreference'; import { useIntelligence } from '../hooks/useIntelligence'; +import { useSlideThumbnails } from '../hooks/useSlideThumbnails'; +import { useCursorToSlide } from '../hooks/useCursorToSlide'; import { diffToMonacoEdits } from '../utils/diffToMonacoEdits'; import { diagnosticsToMarkers } from '../utils/diagnosticToMonaco'; -import Preview from './Preview'; import FileSidebar from './FileSidebar'; import NewFileDialog from './NewFileDialog'; import ShareDialog from './ShareDialog'; @@ -35,6 +36,7 @@ import ViewToggleControl from './ViewToggleControl'; import { useViewMode } from './ViewModeContext'; import MarkdownSummary from './MarkdownSummary'; import './Editor.css'; +import ReactPreview from './ReactPreview'; interface Props { project: ProjectEntry; @@ -149,6 +151,21 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC // Monaco instance ref for setting markers const monacoRef = useRef(null); + // Content version for triggering thumbnail regeneration + const [contentVersion, setContentVersion] = useState(0); + + // AST JSON for slide thumbnails + const [astJson, setAstJson] = useState(null); + + // Generate thumbnails for slides + const thumbnails = useSlideThumbnails({ + astJson, + currentFilePath: currentFile?.path ?? '', + symbols, + previewReady: wasmStatus === 'ready' && editorReady, + contentVersion, + }); + // New file dialog state const [showNewFileDialog, setShowNewFileDialog] = useState(false); const [pendingUploadFiles, setPendingUploadFiles] = useState([]); @@ -165,6 +182,31 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC const [isEditorDragOver, setIsEditorDragOver] = useState(false); const pendingDropPositionRef = useRef(null); + // Fullscreen preview mode + const [isFullscreenPreview, setIsFullscreenPreview] = useState(false); + + // Current slide index (for cursor-driven slide navigation) + const [currentSlideIndex, setCurrentSlideIndex] = useState(0); + + // Map cursor position to slide index + const getSlideForLine = useCursorToSlide(astJson, symbols); + + // Keep getSlideForLine in a ref so the cursor listener always has the latest version + const getSlideForLineRef = useRef(getSlideForLine); + useEffect(() => { + getSlideForLineRef.current = getSlideForLine; + }, [getSlideForLine]); + + // Handle manual slide changes (from arrow keys or buttons in preview) + const handleSlideChange = useCallback((slideIndex: number) => { + setCurrentSlideIndex(slideIndex); + }, []); + + // Toggle fullscreen preview mode + const handleToggleFullscreenPreview = useCallback(() => { + setIsFullscreenPreview(prev => !prev); + }, []); + // Callback for when preview wants to change file (via link click - adds history) const handlePreviewFileChange = useCallback((file: FileEntry, anchor?: string) => { setCurrentFile(file); @@ -194,6 +236,13 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC setWasmError(error); }, []); + // Callback for when preview AST changes + const handleAstChange = useCallback((newAstJson: string | null) => { + setAstJson(newAstJson); + // Increment content version to trigger thumbnail regeneration + setContentVersion(prev => prev + 1); + }, []); + // Update document title based on current file and project useEffect(() => { if (currentFile) { @@ -316,6 +365,7 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC if (value !== undefined && currentFile) { setContent(value); onContentChange(currentFile.path, value); + // Thumbnail regeneration will be triggered by handleAstChange when preview finishes } }; @@ -337,6 +387,14 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC editorHasFocusRef.current = false; }); + // Track cursor position changes for slide navigation + editor.onDidChangeCursorPosition((e) => { + // Get the cursor line (0-based in Monaco) + const line = e.position.lineNumber - 1; // Convert to 0-based for our mapping + const slideIndex = getSlideForLineRef.current(line); + setCurrentSlideIndex(slideIndex); + }); + // Attach drag-drop handlers to editor container const domNode = editor.getDomNode(); if (domNode) { @@ -636,14 +694,18 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC return (
- + {!isFullscreenPreview && ( + + )} - {unlocatedErrors.length > 0 && ( + {!isFullscreenPreview && unlocatedErrors.length > 0 && (
{unlocatedErrors.map((diag, i) => (
@@ -656,79 +718,83 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC )}
- - {(activeTab) => { - switch (activeTab) { - case 'files': - return ( - - ); - case 'outline': - return ( - - ); - case 'project': - return ( - - ); - case 'status': - return ( - - ); - case 'settings': - return ( - - ); - case 'about': - return ; - default: - return null; - } - }} - -
- {/* Show MarkdownSummary overlay in preview mode */} - {viewMode === 'preview' && ( -
- { - if (previewScrollToLineRef.current) { - previewScrollToLineRef.current(lineNumber); - } - }} - /> -
- )} - {/* Always render Monaco but hide in preview mode */} -
+ {!isFullscreenPreview && ( + + {(activeTab) => { + switch (activeTab) { + case 'files': + return ( + + ); + case 'outline': + return ( + + ); + case 'project': + return ( + + ); + case 'status': + return ( + + ); + case 'settings': + return ( + + ); + case 'about': + return ; + default: + return null; + } + }} + + )} + {!isFullscreenPreview && ( +
+ {/* Show MarkdownSummary overlay in preview mode */} + {viewMode === 'preview' && ( +
+ { + if (previewScrollToLineRef.current) { + previewScrollToLineRef.current(lineNumber); + } + }} + /> +
+ )} + {/* Always render Monaco but hide in preview mode */} +
-
+
+ )} {/* Divider with view toggle control */} -
- + {!isFullscreenPreview && ( +
+ +
+ )} + +
+ {isFullscreenPreview && ( + + )} +
- - { previewScrollToLineRef.current = fn; }} - />
{/* New file dialog */} diff --git a/hub-client/src/components/MinimalHeader.css b/hub-client/src/components/MinimalHeader.css index e8474bfe..8ba7bbff 100644 --- a/hub-client/src/components/MinimalHeader.css +++ b/hub-client/src/components/MinimalHeader.css @@ -44,6 +44,30 @@ text-align: right; } +.header-right .preview-btn { + padding: 6px 16px; + background: #ffffff; + border: 2px solid #ffffff; + border-radius: 6px; + color: #1e3a8a; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + box-shadow: 0 2px 8px rgba(255, 255, 255, 0.3); +} + +.header-right .preview-btn:hover { + background: #f0f0f0; + border-color: #f0f0f0; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(255, 255, 255, 0.4); +} + +.header-right .preview-btn:active { + transform: translateY(0); +} + .header-right .choose-project-btn { padding: 4px 10px; background: none; diff --git a/hub-client/src/components/MinimalHeader.tsx b/hub-client/src/components/MinimalHeader.tsx index 3a76712f..a771b1e1 100644 --- a/hub-client/src/components/MinimalHeader.tsx +++ b/hub-client/src/components/MinimalHeader.tsx @@ -13,6 +13,8 @@ interface MinimalHeaderProps { onChooseNewProject: () => void; /** Called when user wants to share the project */ onShare?: () => void; + onToggleFullscreenPreview?: () => void; + isFullscreenPreview?: boolean; } export default function MinimalHeader({ @@ -20,6 +22,8 @@ export default function MinimalHeader({ projectName, onChooseNewProject, onShare, + onToggleFullscreenPreview, + isFullscreenPreview = false, }: MinimalHeaderProps) { return (
@@ -37,6 +41,11 @@ export default function MinimalHeader({ Share )} + {onToggleFullscreenPreview && !isFullscreenPreview && ( + + )} diff --git a/hub-client/src/components/OutlinePanel.css b/hub-client/src/components/OutlinePanel.css index 9013bb65..63b1f969 100644 --- a/hub-client/src/components/OutlinePanel.css +++ b/hub-client/src/components/OutlinePanel.css @@ -30,6 +30,7 @@ .outline-item { margin: 0; padding: 0; + cursor: pointer; } .outline-row { @@ -59,8 +60,9 @@ } /* Indent items without chevrons to align with those that have them */ -.outline-item:not(.has-children) > .outline-row { - padding-left: 28px; /* 12px base + 16px chevron width */ +.outline-item:not(.has-children)>.outline-row { + padding-left: 28px; + /* 12px base + 16px chevron width */ } .outline-button { @@ -92,32 +94,36 @@ padding-left: 24px; } -.outline-list .outline-list .outline-item:not(.has-children) > .outline-row { - padding-left: 40px; /* 24px + 16px */ +.outline-list .outline-list .outline-item:not(.has-children)>.outline-row { + padding-left: 40px; + /* 24px + 16px */ } .outline-list .outline-list .outline-list .outline-row { padding-left: 36px; } -.outline-list .outline-list .outline-list .outline-item:not(.has-children) > .outline-row { - padding-left: 52px; /* 36px + 16px */ +.outline-list .outline-list .outline-list .outline-item:not(.has-children)>.outline-row { + padding-left: 52px; + /* 36px + 16px */ } .outline-list .outline-list .outline-list .outline-list .outline-row { padding-left: 48px; } -.outline-list .outline-list .outline-list .outline-list .outline-item:not(.has-children) > .outline-row { - padding-left: 64px; /* 48px + 16px */ +.outline-list .outline-list .outline-list .outline-list .outline-item:not(.has-children)>.outline-row { + padding-left: 64px; + /* 48px + 16px */ } .outline-list .outline-list .outline-list .outline-list .outline-list .outline-row { padding-left: 60px; } -.outline-list .outline-list .outline-list .outline-list .outline-list .outline-item:not(.has-children) > .outline-row { - padding-left: 76px; /* 60px + 16px */ +.outline-list .outline-list .outline-list .outline-list .outline-list .outline-item:not(.has-children)>.outline-row { + padding-left: 76px; + /* 60px + 16px */ } .outline-icon { @@ -155,9 +161,17 @@ } @keyframes ellipsis { - 0% { content: '.'; } - 33% { content: '..'; } - 66% { content: '...'; } + 0% { + content: '.'; + } + + 33% { + content: '..'; + } + + 66% { + content: '...'; + } } /* Empty state */ @@ -187,4 +201,4 @@ .outline-icon.function { color: #dcdcaa; -} +} \ No newline at end of file diff --git a/hub-client/src/components/OutlinePanel.tsx b/hub-client/src/components/OutlinePanel.tsx index 65f5b944..4d51bfcf 100644 --- a/hub-client/src/components/OutlinePanel.tsx +++ b/hub-client/src/components/OutlinePanel.tsx @@ -9,6 +9,7 @@ import { useState, useCallback } from 'react'; import type { Symbol, SymbolKind } from '../types/intelligence'; +import type { ThumbnailMap } from '../hooks/useSectionThumbnails'; import './OutlinePanel.css'; /** @@ -28,6 +29,8 @@ export interface OutlinePanelProps { loading?: boolean; /** Error message to display. */ error?: string | null; + /** Thumbnail images for sections (keyed by line number). */ + thumbnails?: ThumbnailMap; } /** @@ -64,11 +67,13 @@ function SymbolTree({ onSymbolClick, collapsedSymbols, onToggleSymbol, + thumbnails, }: { symbols: Symbol[]; onSymbolClick: (symbol: Symbol) => void; collapsedSymbols: Set; onToggleSymbol: (symbolId: string) => void; + thumbnails?: ThumbnailMap; }) { if (symbols.length === 0) { return null; @@ -81,6 +86,7 @@ function SymbolTree({ const hasChildren = symbol.children && symbol.children.length > 0; const isCollapsed = collapsedSymbols.has(symbolId); const { icon, className } = getSymbolIcon(symbol.kind); + const thumbnail = thumbnails?.get(symbol.range.start.line); return (
  • {symbol.detail} )} + {thumbnail && ( + {`Thumbnail + )}
  • {hasChildren && !isCollapsed && ( )} @@ -139,6 +161,7 @@ export default function OutlinePanel({ onSymbolClick, loading = false, error = null, + thumbnails, }: OutlinePanelProps) { // Track collapsed symbols (inverted logic: store collapsed, not expanded) // This means new symbols are expanded by default @@ -192,6 +215,7 @@ export default function OutlinePanel({ onSymbolClick={onSymbolClick} collapsedSymbols={collapsedSymbols} onToggleSymbol={toggleSymbol} + thumbnails={thumbnails} />
    ); diff --git a/hub-client/src/components/ReactAstRenderer.tsx b/hub-client/src/components/ReactAstRenderer.tsx new file mode 100644 index 00000000..6ed1540d --- /dev/null +++ b/hub-client/src/components/ReactAstRenderer.tsx @@ -0,0 +1,337 @@ +import React from 'react'; + +/** + * Simplified Pandoc AST types for rendering + */ +interface PandocAST { + 'pandoc-api-version': [number, number, number]; + meta: Record; + blocks: Block[]; +} + +type ParaBlock = { t: 'Para'; c: Inline[] }; +type PlainBlock = { t: 'Plain'; c: Inline[] }; +type HeaderBlock = { t: 'Header'; c: [number, [string, string[], [string, string][]], Inline[]] }; +type CodeBlock = { t: 'CodeBlock'; c: [[string, string[], [string, string][]], string] }; +type BulletListBlock = { t: 'BulletList'; c: Block[][] }; +type OrderedListBlock = { t: 'OrderedList'; c: [[number, { t: string }, { t: string }], Block[][]] }; +type BlockQuoteBlock = { t: 'BlockQuote'; c: Block[] }; +type DivBlock = { t: 'Div'; c: [[string, string[], [string, string][]], Block[]] }; +type HorizontalRuleBlock = { t: 'HorizontalRule' }; +type RawBlock = { t: 'RawBlock'; c: [string, string] }; +type FigureBlock = { t: 'Figure'; c: [[string, string[], [string, string][]], [Inline[] | null, Block[]], Block[]] }; +type UnknownBlock = { t: string; c?: unknown }; + +type Block = + | ParaBlock + | PlainBlock + | HeaderBlock + | CodeBlock + | BulletListBlock + | OrderedListBlock + | BlockQuoteBlock + | DivBlock + | HorizontalRuleBlock + | RawBlock + | FigureBlock + | UnknownBlock; + +type StrInline = { t: 'Str'; c: string }; +type SpaceInline = { t: 'Space' }; +type SoftBreakInline = { t: 'SoftBreak' }; +type LineBreakInline = { t: 'LineBreak' }; +type EmphInline = { t: 'Emph'; c: Inline[] }; +type StrongInline = { t: 'Strong'; c: Inline[] }; +type CodeInline = { t: 'Code'; c: [[string, string[], [string, string][]], string] }; +type LinkInline = { t: 'Link'; c: [[string, string[], [string, string][]], Inline[], [string, string]] }; +type ImageInline = { t: 'Image'; c: [[string, string[], [string, string][]], Inline[], [string, string]] }; +type SpanInline = { t: 'Span'; c: [[string, string[], [string, string][]], Inline[]] }; +type UnknownInline = { t: string; c?: unknown }; + +type Inline = + | StrInline + | SpaceInline + | SoftBreakInline + | LineBreakInline + | EmphInline + | StrongInline + | CodeInline + | LinkInline + | ImageInline + | SpanInline + | UnknownInline; + +interface PandocAstRendererProps { + astJson: string; + onNavigateToDocument?: (path: string, anchor: string | null) => void; +} + +/** + * Component that renders Pandoc AST as React elements + */ +export function Ast({ astJson, onNavigateToDocument }: PandocAstRendererProps) { + let ast: PandocAST; + + try { + ast = JSON.parse(astJson); + } catch (err) { + return ( +
    + Failed to parse AST: {err instanceof Error ? err.message : String(err)} +
    + ); + } + + return ( +
    + {ast.blocks.map((block, i) => renderBlock(block, i, onNavigateToDocument))} +
    + ); +} + +// ============================================================================ +// Block Rendering +// ============================================================================ + +function renderBlock( + block: Block, + key: number, + onNavigateToDocument?: (path: string, anchor: string | null) => void +): React.ReactNode { + switch (block.t) { + case 'Para': { + const paraBlock = block as ParaBlock; + return

    {renderInlines(paraBlock.c, onNavigateToDocument)}

    ; + } + + case 'Plain': { + const plainBlock = block as PlainBlock; + return
    {renderInlines(plainBlock.c, onNavigateToDocument)}
    ; + } + + case 'Header': { + const headerBlock = block as HeaderBlock; + const [level, [id, classes, attrs], inlines] = headerBlock.c; + const Tag = `h${level}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; + const className = classes.join(' '); + const attrObj = Object.fromEntries(attrs); + return ( + + {renderInlines(inlines, onNavigateToDocument)} + + ); + } + + case 'CodeBlock': { + const codeBlock = block as CodeBlock; + const [[id, classes, attrs], code] = codeBlock.c; + const className = classes.join(' '); + const attrObj = Object.fromEntries(attrs); + return ( +
    +          {code}
    +        
    + ); + } + + case 'BulletList': { + const bulletList = block as BulletListBlock; + return ( +
      + {bulletList.c.map((item, i) => ( +
    • + {item.map((b, j) => renderBlock(b, j, onNavigateToDocument))} +
    • + ))} +
    + ); + } + + case 'OrderedList': { + const orderedList = block as OrderedListBlock; + const [[start, _style, _delim], items] = orderedList.c; + return ( +
      + {items.map((item, i) => ( +
    1. + {item.map((b, j) => renderBlock(b, j, onNavigateToDocument))} +
    2. + ))} +
    + ); + } + + case 'BlockQuote': { + const blockQuote = block as BlockQuoteBlock; + return ( +
    + {blockQuote.c.map((b, i) => renderBlock(b, i, onNavigateToDocument))} +
    + ); + } + + case 'Div': { + const divBlock = block as DivBlock; + const [[id, classes, attrs], blocks] = divBlock.c; + const className = classes.join(' '); + const attrObj = Object.fromEntries(attrs); + return ( +
    + {blocks.map((b, i) => renderBlock(b, i, onNavigateToDocument))} +
    + ); + } + + case 'HorizontalRule': + return
    ; + + case 'RawBlock': { + const rawBlock = block as RawBlock; + const [format, content] = rawBlock.c; + if (format === 'html') { + return
    ; + } + return null; + } + + case 'Figure': { + const figureBlock = block as FigureBlock; + const [[id, classes, attrs], [caption, _blocks], content] = figureBlock.c; + const className = classes.join(' '); + const attrObj = Object.fromEntries(attrs); + + return ( +
    + {content.map((b, i) => renderBlock(b, i, onNavigateToDocument))} + {caption && caption.length > 0 && ( +
    {renderInlines(caption, onNavigateToDocument)}
    + )} +
    + ); + } + + default: + console.warn('Unhandled block type:', block.t); + return
    [{block.t}]
    ; + } +} + +// ============================================================================ +// Inline Rendering +// ============================================================================ + +function renderInlines( + inlines: Inline[], + onNavigateToDocument?: (path: string, anchor: string | null) => void +): React.ReactNode[] { + return inlines.map((inline, i) => renderInline(inline, i, onNavigateToDocument)); +} + +function renderInline( + inline: Inline, + key: number, + onNavigateToDocument?: (path: string, anchor: string | null) => void +): React.ReactNode { + switch (inline.t) { + case 'Str': { + const strInline = inline as StrInline; + return strInline.c; + } + + case 'Space': + return ' '; + + case 'SoftBreak': + return ' '; + + case 'LineBreak': + return
    ; + + case 'Emph': { + const emphInline = inline as EmphInline; + return {renderInlines(emphInline.c, onNavigateToDocument)}; + } + + case 'Strong': { + const strongInline = inline as StrongInline; + return {renderInlines(strongInline.c, onNavigateToDocument)}; + } + + case 'Code': { + const codeInline = inline as CodeInline; + const [[id, classes, attrs], code] = codeInline.c; + const className = classes.join(' '); + const attrObj = Object.fromEntries(attrs); + return ( + + {code} + + ); + } + + case 'Link': { + const linkInline = inline as LinkInline; + const [[id, classes, attrs], inlines, [url, title]] = linkInline.c; + const className = classes.join(' '); + const attrObj = Object.fromEntries(attrs); + + // Handle .qmd links + if (url.endsWith('.qmd') && onNavigateToDocument) { + const [path, anchor] = url.split('#'); + return ( + { + e.preventDefault(); + onNavigateToDocument(path, anchor || null); + }} + > + {renderInlines(inlines, onNavigateToDocument)} + + ); + } + + return ( + + {renderInlines(inlines, onNavigateToDocument)} + + ); + } + + case 'Image': { + const imageInline = inline as ImageInline; + const [[id, classes, attrs], inlines, [url, title]] = imageInline.c; + const className = classes.join(' '); + const attrObj = Object.fromEntries(attrs); + const alt = inlines.map(i => { + if ('c' in i && typeof i.c === 'string') return i.c; + return ''; + }).join(''); + + return ( + {alt} + ); + } + + case 'Span': { + const spanInline = inline as SpanInline; + const [[id, classes, attrs], inlines] = spanInline.c; + const className = classes.join(' '); + const attrObj = Object.fromEntries(attrs); + return ( + + {renderInlines(inlines, onNavigateToDocument)} + + ); + } + + default: + console.warn('Unhandled inline type:', inline.t); + return [{inline.t}]; + } +} diff --git a/hub-client/src/components/ReactAstSlideRenderer.tsx b/hub-client/src/components/ReactAstSlideRenderer.tsx new file mode 100644 index 00000000..e0e0d9ea --- /dev/null +++ b/hub-client/src/components/ReactAstSlideRenderer.tsx @@ -0,0 +1,944 @@ +import React, { useState, useEffect } from 'react'; +import { AspectRatioScaler } from './AspectRatioScaler'; +import katex from 'katex'; +import 'katex/dist/katex.min.css'; +import { vfsReadFile, vfsReadBinaryFile } from '../services/wasmRenderer'; + +/** + * Simplified Pandoc AST types for rendering + * Exported for use in thumbnail generation. + */ +export interface PandocAST { + 'pandoc-api-version': [number, number, number]; + meta: Record; + blocks: Block[]; +} + +/** + * Represents a single slide with its content + * Exported for use in thumbnail generation. + */ +export interface Slide { + type: 'title' | 'content'; + title?: string; + author?: string; + blocks: Block[]; +} + +type ParaBlock = { t: 'Para'; c: Inline[] }; +type PlainBlock = { t: 'Plain'; c: Inline[] }; +type HeaderBlock = { t: 'Header'; c: [number, [string, string[], [string, string][]], Inline[]] }; +type CodeBlock = { t: 'CodeBlock'; c: [[string, string[], [string, string][]], string] }; +type BulletListBlock = { t: 'BulletList'; c: Block[][] }; +type OrderedListBlock = { t: 'OrderedList'; c: [[number, { t: string }, { t: string }], Block[][]] }; +type BlockQuoteBlock = { t: 'BlockQuote'; c: Block[] }; +type DivBlock = { t: 'Div'; c: [[string, string[], [string, string][]], Block[]] }; +type HorizontalRuleBlock = { t: 'HorizontalRule' }; +type RawBlock = { t: 'RawBlock'; c: [string, string] }; +type FigureBlock = { t: 'Figure'; c: [[string, string[], [string, string][]], [Inline[] | null, Block[]], Block[]] }; +type UnknownBlock = { t: string; c?: unknown }; + +type Block = + | ParaBlock + | PlainBlock + | HeaderBlock + | CodeBlock + | BulletListBlock + | OrderedListBlock + | BlockQuoteBlock + | DivBlock + | HorizontalRuleBlock + | RawBlock + | FigureBlock + | UnknownBlock; + +type StrInline = { t: 'Str'; c: string }; +type SpaceInline = { t: 'Space' }; +type SoftBreakInline = { t: 'SoftBreak' }; +type LineBreakInline = { t: 'LineBreak' }; +type EmphInline = { t: 'Emph'; c: Inline[] }; +type StrongInline = { t: 'Strong'; c: Inline[] }; +type CodeInline = { t: 'Code'; c: [[string, string[], [string, string][]], string] }; +type LinkInline = { t: 'Link'; c: [[string, string[], [string, string][]], Inline[], [string, string]] }; +type ImageInline = { t: 'Image'; c: [[string, string[], [string, string][]], Inline[], [string, string]] }; +type SpanInline = { t: 'Span'; c: [[string, string[], [string, string][]], Inline[]] }; +type MathInline = { t: 'Math'; c: [{ t: string }, string] }; +type UnknownInline = { t: string; c?: unknown }; + +type Inline = + | StrInline + | SpaceInline + | SoftBreakInline + | LineBreakInline + | EmphInline + | StrongInline + | CodeInline + | LinkInline + | ImageInline + | SpanInline + | MathInline + | UnknownInline; + +interface PandocAstSlideRendererProps { + astJson: string; + /** Current file path for resolving relative image paths */ + currentFilePath: string; + onNavigateToDocument?: (path: string, anchor: string | null) => void; + /** Optional controlled current slide index. If provided, component uses this instead of internal state. */ + currentSlide?: number; + /** Callback when current slide changes (for controlled mode). */ + onSlideChange?: (slideIndex: number) => void; +} + +/** + * Component that renders Pandoc AST as React elements for slides + */ +export function SlideAst({ astJson, currentFilePath, onNavigateToDocument, currentSlide: controlledSlide, onSlideChange }: PandocAstSlideRendererProps) { + const [internalSlide, setInternalSlide] = useState(0); + + // Use controlled slide if provided, otherwise use internal state + const currentSlide = controlledSlide !== undefined ? controlledSlide : internalSlide; + const setCurrentSlide = (value: number | ((prev: number) => number)) => { + const newValue = typeof value === 'function' ? value(currentSlide) : value; + if (controlledSlide !== undefined) { + // Controlled mode - notify parent + onSlideChange?.(newValue); + } else { + // Uncontrolled mode - update internal state + setInternalSlide(newValue); + } + }; + + let ast: PandocAST; + + try { + ast = JSON.parse(astJson); + } catch (err) { + return ( +
    + Failed to parse AST: {err instanceof Error ? err.message : String(err)} +
    + ); + } + + // Parse blocks into slides + const slides = parseSlides(ast); + + // Clamp slide index if it's out of bounds (e.g., slides were removed during editing) + useEffect(() => { + if (currentSlide >= slides.length && slides.length > 0) { + setCurrentSlide(slides.length - 1); + } + }, [slides.length, currentSlide]); + + // Keyboard navigation + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'ArrowLeft') { + setCurrentSlide(prev => Math.max(0, prev - 1)); + } else if (e.key === 'ArrowRight') { + setCurrentSlide(prev => Math.min(slides.length - 1, prev + 1)); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [slides.length, setCurrentSlide]); + + const goToPrevSlide = () => setCurrentSlide(prev => Math.max(0, prev - 1)); + const goToNextSlide = () => setCurrentSlide(prev => Math.min(slides.length - 1, prev + 1)); + + return ( +
    + {/* AspectRatioScaler handles sizing and scaling */} + +
    + {renderSlide(slides[currentSlide], currentFilePath, onNavigateToDocument)} +
    +
    + + {/* Navigation buttons */} +
    + + +
    + + {/* Slide counter */} +
    + {currentSlide + 1} / {slides.length} +
    +
    + ); +} + +// ============================================================================ +// Slide Parsing (exported for thumbnail generation) +// ============================================================================ + +/** + * Parse AST blocks into slides. + * Strategy: + * 1. Check if blocks are section Divs (Pandoc wraps sections in Divs) + * 2. If so, each section Div becomes a slide + * 3. Otherwise, split on h1/h2 headers + * + * Exported for use in thumbnail generation. + */ +export function parseSlides(ast: PandocAST): Slide[] { + const slides: Slide[] = []; + + // Extract title and author from metadata + const title = extractMetaString(ast.meta.title); + const author = extractMetaString(ast.meta.author); + + // Add title slide if we have title or author + if (title || author) { + slides.push({ + type: 'title', + title, + author, + blocks: [] + }); + } + + // Check if blocks are section Divs (look for Divs with headers as first block) + const sections = extractSections(ast.blocks); + + if (sections.length > 0) { + // Use section-based splitting + for (const section of sections) { + slides.push({ + type: 'content', + blocks: section + }); + } + } else { + // Fall back to header-based splitting + const flattenedBlocks = flattenBlocks(ast.blocks); + const contentSlides = splitByHeaders(flattenedBlocks); + slides.push(...contentSlides); + } + + return slides; +} + +/** + * Extract sections from blocks. Each section Div becomes a slide. + * Returns empty array if blocks don't follow section pattern. + */ +function extractSections(blocks: Block[]): Block[][] { + const sections: Block[][] = []; + + for (const block of blocks) { + if (block.t === 'Div') { + const divBlock = block as DivBlock; + const [[, classes], innerBlocks] = divBlock.c; + + // Check if this Div looks like a section + // (has "section" class OR first block is a header) + const isSection = classes.includes('section') || + (innerBlocks.length > 0 && innerBlocks[0].t === 'Header'); + + if (isSection) { + sections.push(innerBlocks); + } + } + } + + // Only return sections if ALL top-level blocks are section Divs + // Otherwise return empty to trigger header-based splitting + return sections.length === blocks.length ? sections : []; +} + +/** + * Split blocks into slides based on h1/h2 headers + */ +function splitByHeaders(blocks: Block[]): Slide[] { + const slides: Slide[] = []; + let currentSlideBlocks: Block[] = []; + + for (const block of blocks) { + if (block.t === 'Header') { + const headerBlock = block as HeaderBlock; + const [level] = headerBlock.c; + + if (level === 1 || level === 2) { + // Save previous slide if it has content + if (currentSlideBlocks.length > 0) { + slides.push({ + type: 'content', + blocks: currentSlideBlocks + }); + } + + // Start new slide with this heading + currentSlideBlocks = [block]; + } else { + // h3, h4, etc. - add to current slide + currentSlideBlocks.push(block); + } + } else { + // Non-heading block - add to current slide + currentSlideBlocks.push(block); + } + } + + // Add final slide if it has content + if (currentSlideBlocks.length > 0) { + slides.push({ + type: 'content', + blocks: currentSlideBlocks + }); + } + + return slides; +} + +/** + * Flatten block structure by extracting blocks from Divs + * This handles the case where sections are wrapped in Div containers + */ +function flattenBlocks(blocks: Block[]): Block[] { + const result: Block[] = []; + + for (const block of blocks) { + if (block.t === 'Div') { + const divBlock = block as DivBlock; + const [, innerBlocks] = divBlock.c; + // Recursively flatten inner blocks + result.push(...flattenBlocks(innerBlocks)); + } else { + result.push(block); + } + } + + return result; +} + +/** + * Extract a string value from Pandoc metadata + */ +function extractMetaString(meta: unknown): string | undefined { + if (!meta) return undefined; + + // Handle MetaInlines (most common for title/author) + if (typeof meta === 'object' && meta !== null && 't' in meta) { + const metaObj = meta as { t: string; c?: unknown }; + if (metaObj.t === 'MetaInlines' && Array.isArray(metaObj.c)) { + return metaObj.c + .map((inline: any) => { + if (inline.t === 'Str') return inline.c; + if (inline.t === 'Space') return ' '; + return ''; + }) + .join(''); + } + if (metaObj.t === 'MetaString' && typeof metaObj.c === 'string') { + return metaObj.c; + } + } + + return undefined; +} + +/** + * Render a single slide + * Exported for use in thumbnail generation. + */ +export function renderSlide( + slide: Slide, + currentFilePath: string, + onNavigateToDocument?: (path: string, anchor: string | null) => void +): React.ReactNode { + if (slide.type === 'title') { + return ( +
    + {slide.title && ( +

    + {slide.title} +

    + )} + {slide.author && ( +

    + {slide.author} +

    + )} +
    + ); + } + + // Content slide + return ( +
    + {slide.blocks.map((block, i) => renderBlock(block, i, currentFilePath, onNavigateToDocument))} +
    + ); +} + +// ============================================================================ +// Attribute Helpers +// ============================================================================ + +/** + * Convert Pandoc attributes to React-compatible props. + * Handles the special case where 'style' might be a string and needs to be parsed. + */ +function attributesToProps( + id: string, + classes: string[], + attrs: [string, string][], + additionalStyle?: React.CSSProperties +): { + id?: string; + className?: string; + style?: React.CSSProperties; + [key: string]: any; +} { + const className = classes.join(' '); + const attrObj: { [key: string]: any } = {}; + let styleString: string | undefined; + + // Separate style from other attributes + for (const [key, value] of attrs) { + if (key === 'style') { + styleString = value; + } else { + attrObj[key] = value; + } + } + + // Parse style string into style object + let parsedStyle: React.CSSProperties = {}; + if (styleString) { + parsedStyle = parseStyleString(styleString); + } + + // Merge with additional styles + const style = { ...parsedStyle, ...additionalStyle }; + + return { + ...(id ? { id } : {}), + ...(className ? { className } : {}), + ...(Object.keys(style).length > 0 ? { style } : {}), + ...attrObj + }; +} + +/** + * Parse a CSS style string (e.g., "color: red; font-size: 14px") into a React style object. + */ +function parseStyleString(styleString: string): React.CSSProperties { + const style: React.CSSProperties = {}; + + // Split by semicolon and process each declaration + const declarations = styleString.split(';').map(s => s.trim()).filter(Boolean); + + for (const declaration of declarations) { + const colonIndex = declaration.indexOf(':'); + if (colonIndex === -1) continue; + + const property = declaration.slice(0, colonIndex).trim(); + const value = declaration.slice(colonIndex + 1).trim(); + + // Convert CSS property names to camelCase (e.g., "font-size" -> "fontSize") + const camelCaseProperty = property.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); + + // @ts-ignore - Dynamic property assignment + style[camelCaseProperty] = value; + } + + return style; +} + +// ============================================================================ +// Block Rendering +// ============================================================================ + +function renderBlock( + block: Block, + key: number, + currentFilePath: string, + onNavigateToDocument?: (path: string, anchor: string | null) => void +): React.ReactNode { + switch (block.t) { + case 'Para': { + const paraBlock = block as ParaBlock; + return ( +

    + {renderInlines(paraBlock.c, currentFilePath, onNavigateToDocument)} +

    + ); + } + + case 'Plain': { + const plainBlock = block as PlainBlock; + return ( +
    + {renderInlines(plainBlock.c, currentFilePath, onNavigateToDocument)} +
    + ); + } + + case 'Header': { + const headerBlock = block as HeaderBlock; + const [level, [id, classes, attrs], inlines] = headerBlock.c; + const Tag = `h${level}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; + + // Slide-appropriate header styles + const headerStyles: React.CSSProperties = { + marginTop: level <= 2 ? '0' : '0.5em', + marginBottom: '0.5em', + color: '#1a1a1a', + fontWeight: 'bold' + }; + + if (level === 1) { + headerStyles.fontSize = '64px'; + } else if (level === 2) { + headerStyles.fontSize = '52px'; + } else if (level === 3) { + headerStyles.fontSize = '40px'; + } + + const props = attributesToProps(id, classes, attrs, headerStyles); + + return ( + + {renderInlines(inlines, currentFilePath, onNavigateToDocument)} + + ); + } + + case 'CodeBlock': { + const codeBlock = block as CodeBlock; + const [[id, classes, attrs], code] = codeBlock.c; + const codeBlockStyle: React.CSSProperties = { + background: '#f5f5f5', + padding: '20px', + borderRadius: '8px', + overflow: 'auto', + fontSize: '20px', + marginTop: '0.5em', + marginBottom: '0.5em' + }; + const props = attributesToProps(id, classes, attrs, codeBlockStyle); + return ( +
    +          {code}
    +        
    + ); + } + + case 'BulletList': { + const bulletList = block as BulletListBlock; + return ( +
      + {bulletList.c.map((item, i) => ( +
    • + {item.map((b, j) => renderBlock(b, j, currentFilePath, onNavigateToDocument))} +
    • + ))} +
    + ); + } + + case 'OrderedList': { + const orderedList = block as OrderedListBlock; + const [[start, _style, _delim], items] = orderedList.c; + return ( +
      + {items.map((item, i) => ( +
    1. + {item.map((b, j) => renderBlock(b, j, currentFilePath, onNavigateToDocument))} +
    2. + ))} +
    + ); + } + + case 'BlockQuote': { + const blockQuote = block as BlockQuoteBlock; + return ( +
    + {blockQuote.c.map((b, i) => renderBlock(b, i, currentFilePath, onNavigateToDocument))} +
    + ); + } + + case 'Div': { + const divBlock = block as DivBlock; + const [[id, classes, attrs], blocks] = divBlock.c; + const props = attributesToProps(id, classes, attrs); + return ( +
    + {blocks.map((b, i) => renderBlock(b, i, currentFilePath, onNavigateToDocument))} +
    + ); + } + + case 'HorizontalRule': + return
    ; + + case 'RawBlock': { + const rawBlock = block as RawBlock; + const [format, content] = rawBlock.c; + if (format === 'html') { + return
    ; + } + return null; + } + + case 'Figure': { + const figureBlock = block as FigureBlock; + const [[id, classes, attrs], [caption, _blocks], content] = figureBlock.c; + const figureStyle: React.CSSProperties = { + margin: 0, + }; + const props = attributesToProps(id, classes, attrs, figureStyle); + + return ( +
    + {content.map((b, i) => renderBlock(b, i, currentFilePath, onNavigateToDocument))} + {caption && caption.length > 0 && ( +
    {renderInlines(caption, currentFilePath, onNavigateToDocument)}
    + )} +
    + ); + } + + default: + console.warn('Unhandled block type:', block.t); + return
    [{block.t}]
    ; + } +} + +// ============================================================================ +// Inline Rendering +// ============================================================================ + +function renderInlines( + inlines: Inline[], + currentFilePath: string, + onNavigateToDocument?: (path: string, anchor: string | null) => void +): React.ReactNode[] { + return inlines.map((inline, i) => renderInline(inline, i, currentFilePath, onNavigateToDocument)); +} + +function renderInline( + inline: Inline, + key: number, + currentFilePath: string, + onNavigateToDocument?: (path: string, anchor: string | null) => void +): React.ReactNode { + switch (inline.t) { + case 'Str': { + const strInline = inline as StrInline; + return strInline.c; + } + + case 'Space': + return ' '; + + case 'SoftBreak': + return ' '; + + case 'LineBreak': + return
    ; + + case 'Emph': { + const emphInline = inline as EmphInline; + return {renderInlines(emphInline.c, currentFilePath, onNavigateToDocument)}; + } + + case 'Strong': { + const strongInline = inline as StrongInline; + return {renderInlines(strongInline.c, currentFilePath, onNavigateToDocument)}; + } + + case 'Code': { + const codeInline = inline as CodeInline; + const [[id, classes, attrs], code] = codeInline.c; + const props = attributesToProps(id, classes, attrs); + return ( + + {code} + + ); + } + + case 'Link': { + const linkInline = inline as LinkInline; + const [[id, classes, attrs], inlines, [url, title]] = linkInline.c; + const props = attributesToProps(id, classes, attrs); + + // Handle .qmd links + if (url.endsWith('.qmd') && onNavigateToDocument) { + const [path, anchor] = url.split('#'); + return ( + { + e.preventDefault(); + onNavigateToDocument(path, anchor || null); + }} + > + {renderInlines(inlines, currentFilePath, onNavigateToDocument)} + + ); + } + + return ( + + {renderInlines(inlines, currentFilePath, onNavigateToDocument)} + + ); + } + + case 'Image': { + const imageInline = inline as ImageInline; + const [[id, classes, attrs], inlines, [url, title]] = imageInline.c; + const props = attributesToProps(id, classes, attrs); + const alt = inlines.map(i => { + if ('c' in i && typeof i.c === 'string') return i.c; + return ''; + }).join(''); + + // Resolve image source from VFS + let resolvedSrc = url; + + // Skip external URLs and data URIs + if (!url.startsWith('http://') && !url.startsWith('https://') && !url.startsWith('data:')) { + // Handle /.quarto/ paths (built-in resources) + if (url.startsWith('/.quarto/')) { + const result = vfsReadFile(url); + if (result.success && result.content) { + const mimeType = guessMimeType(url); + resolvedSrc = `data:${mimeType};base64,${result.content}`; + } + } else { + // Handle project-relative paths (images uploaded to project) + const resolvedPath = resolveRelativePath(currentFilePath, url); + // Remove leading slash for VFS path (VFS stores as "images/foo.png" not "/images/foo.png") + const vfsPath = resolvedPath.startsWith('/') ? resolvedPath.slice(1) : resolvedPath; + + const result = vfsReadBinaryFile(vfsPath); + if (result.success && result.content) { + const mimeType = guessMimeType(url); + // vfsReadBinaryFile returns base64-encoded content + resolvedSrc = `data:${mimeType};base64,${result.content}`; + } + } + } + + // Add styles to constrain image size + const imageStyle: React.CSSProperties = { + maxWidth: '100%', + maxHeight: '500px', + objectFit: 'contain', + display: 'block', + margin: '0 auto', + }; + + return ( + {alt} + ); + } + + case 'Span': { + const spanInline = inline as SpanInline; + const [[id, classes, attrs], inlines] = spanInline.c; + const props = attributesToProps(id, classes, attrs); + return ( + + {renderInlines(inlines, currentFilePath, onNavigateToDocument)} + + ); + } + + case 'Math': { + const mathInline = inline as MathInline; + const [mathType, latex] = mathInline.c; + const isDisplayMath = mathType.t === 'DisplayMath'; + + try { + const html = katex.renderToString(latex, { + displayMode: isDisplayMath, + throwOnError: false, + output: 'html' + }); + + return ( + + ); + } catch (err) { + console.error('KaTeX rendering error:', err); + return ( + + [Math Error: {latex}] + + ); + } + } + + default: + console.warn('Unhandled inline type:', inline.t); + return [{inline.t}]; + } +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** Resolve a relative path against the current file's directory */ +function resolveRelativePath( + currentFile: string, + relativePath: string +): string { + if (relativePath.startsWith('/')) { + return relativePath; // Already absolute + } + // Get directory of current file + const lastSlash = currentFile.lastIndexOf('/'); + const currentDir = + lastSlash >= 0 ? currentFile.substring(0, lastSlash + 1) : '/'; + return normalizePath(currentDir + relativePath); +} + +function normalizePath(path: string): string { + const parts = path.split('/').filter((p) => p !== '.'); + const result: string[] = []; + for (const part of parts) { + if (part === '..') { + result.pop(); + } else if (part) { + result.push(part); + } + } + return '/' + result.join('/'); +} + +function guessMimeType(path: string): string { + const ext = path.split('.').pop()?.toLowerCase(); + const mimeTypes: Record = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + svg: 'image/svg+xml', + webp: 'image/webp', + }; + return mimeTypes[ext || ''] || 'application/octet-stream'; +} diff --git a/hub-client/src/components/ReactPreview.tsx b/hub-client/src/components/ReactPreview.tsx new file mode 100644 index 00000000..c870ad1c --- /dev/null +++ b/hub-client/src/components/ReactPreview.tsx @@ -0,0 +1,282 @@ +import { useState, useCallback, useRef, useEffect } from 'react'; +import type * as Monaco from 'monaco-editor'; +import type { FileEntry } from '../types/project'; +import { isQmdFile } from '../types/project'; +import type { Diagnostic } from '../types/diagnostic'; +import { initWasm, parseQmdToAst, isWasmReady } from '../services/wasmRenderer'; +import { stripAnsi } from '../utils/stripAnsi'; +import { PreviewErrorOverlay } from './PreviewErrorOverlay'; +import ReactRenderer from './ReactRenderer'; + +// Preview pane state machine: +// START: Initial blank page +// ERROR_AT_START: Error page shown before any successful render +// GOOD: Successfully rendered HTML preview +// ERROR_FROM_GOOD: Error occurred after previous successful render (keep last good HTML, show overlay) +type PreviewState = 'START' | 'ERROR_AT_START' | 'GOOD' | 'ERROR_FROM_GOOD'; + +// Error info for the overlay +interface CurrentError { + message: string; + diagnostics?: Diagnostic[]; // Using intelligence Diagnostic type with range/position +} + +interface PreviewProps { + content: string; + currentFile: FileEntry | null; + files: FileEntry[]; + scrollSyncEnabled: boolean; + editorRef: React.RefObject; + editorReady: boolean; + editorHasFocusRef: React.RefObject; + onFileChange: (file: FileEntry, anchor?: string) => void; + onOpenNewFileDialog: (initialFilename: string) => void; + onDiagnosticsChange: (diagnostics: Diagnostic[]) => void; + onWasmStatusChange?: (status: 'loading' | 'ready' | 'error', error: string | null) => void; + onAstChange?: (astJson: string | null) => void; + currentSlideIndex?: number; + onSlideChange?: (slideIndex: number) => void; +} + +// Result of rendering QMD content to AST +type RenderResult = { + success: true; + astJson: string; + diagnostics: Diagnostic[]; +} | { + success: false; + error: string; + diagnostics: Diagnostic[]; +} + +// Parse QMD content to AST using WASM +// Returns diagnostics and AST JSON string or error message +async function doRender( + qmdContent: string, + _options: { scrollSyncEnabled: boolean; documentPath?: string } +): Promise { + if (!isWasmReady()) { + return { + success: false, + error: 'WASM renderer not ready', + diagnostics: [], + }; + } + + // Parse to AST + const result = await parseQmdToAst(qmdContent); + + // Collect all diagnostics from both success and error paths + const allDiagnostics: Diagnostic[] = [ + ...(result.diagnostics ?? []), + ...(result.warnings ?? []), + ]; + + if (result.success) { + return { + success: true, + astJson: result.ast, + diagnostics: allDiagnostics, + }; + } else { + const errorMsg = + typeof result.error === 'string' + ? result.error + : JSON.stringify(result.error, null, 2) || 'Unknown error'; + + return { + success: false, + diagnostics: allDiagnostics, + error: errorMsg, + }; + } +} + +export default function ReactPreview({ + content, + currentFile, + files, + scrollSyncEnabled, + onFileChange, + onOpenNewFileDialog, + onDiagnosticsChange, + onWasmStatusChange, + onAstChange, + currentSlideIndex, + onSlideChange, +}: PreviewProps) { + const [wasmStatus, setWasmStatus] = useState<'loading' | 'ready' | 'error'>('loading'); + const [wasmError, setWasmError] = useState(null); + + // Notify parent when WASM status changes + useEffect(() => { + onWasmStatusChange?.(wasmStatus, wasmError); + }, [wasmStatus, wasmError, onWasmStatusChange]); + + // Preview state machine for error handling + const [previewState, setPreviewState] = useState('START'); + const [currentError, setCurrentError] = useState(null); + // Track previewState in a ref for use in callbacks + const previewStateRef = useRef('START'); + useEffect(() => { + previewStateRef.current = previewState; + }, [previewState]); + + // Rendered AST JSON to display + const [ast, setAst] = useState(''); + + // Debounce rendering + const renderTimeoutRef = useRef(null); + const lastContentRef = useRef(''); + + // Initialize WASM on mount + useEffect(() => { + let cancelled = false; + + async function init() { + try { + setWasmStatus('loading'); + await initWasm(); + if (!cancelled) { + setWasmStatus('ready'); + } + } catch (err) { + if (!cancelled) { + setWasmStatus('error'); + setWasmError(err instanceof Error ? err.message : String(err)); + } + } + } + + init(); + return () => { cancelled = true; }; + }, []); + + // Handler for cross-document navigation + const handleNavigateToDocument = useCallback( + (targetPath: string, anchor: string | null) => { + const file = files.find( + (f) => f.path === targetPath || '/' + f.path === targetPath + ); + + if (file) { + // Existing file - switch to it + onFileChange(file, anchor ?? undefined); + } else { + // Non-existent file - open create dialog with pre-filled name + // Strip leading slash for the dialog + const filename = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath; + onOpenNewFileDialog(filename); + } + }, + [files, onFileChange, onOpenNewFileDialog] + ); + + // Render function that uses WASM when available + // Implements state machine transitions for error handling: + // - On success: always transition to GOOD, swap to new content + // - On error from START/ERROR_AT_START: show full error page + // - On error from GOOD/ERROR_FROM_GOOD: keep last good AST, show overlay + const doRenderWithStateManagement = useCallback(async (qmdContent: string, documentPath?: string) => { + lastContentRef.current = qmdContent; + + const result = await doRender(qmdContent, { scrollSyncEnabled, documentPath }); + if (qmdContent !== lastContentRef.current) return; + + // Update diagnostics + onDiagnosticsChange(result.diagnostics); + setCurrentError(result.success ? null : { + message: result.error!, + diagnostics: result.diagnostics, + }); + + if (result.success) { + // Success: transition to GOOD state from any state + setPreviewState('GOOD'); + // Update rendered AST + setAst(result.astJson); + // Notify parent of AST change + onAstChange?.(result.astJson); + } else { + // Set current error for overlay + const currentState = previewStateRef.current; + if (currentState === 'START' || currentState === 'ERROR_AT_START') { + // No good render yet - show full error page + setPreviewState('ERROR_AT_START'); + setAst(''); // Clear AST on error + onAstChange?.(null); + } else { + // Was GOOD or ERROR_FROM_GOOD - keep last good AST, show overlay + // DON'T update AST content + setPreviewState('ERROR_FROM_GOOD'); + } + } + }, [scrollSyncEnabled, onDiagnosticsChange]); + + // Immediate render update (no debounce) + const updatePreview = useCallback((newContent: string, documentPath?: string) => { + if (renderTimeoutRef.current) { + clearTimeout(renderTimeoutRef.current); + } + doRenderWithStateManagement(newContent, documentPath); + }, [doRenderWithStateManagement]); + + // Re-render when content changes, WASM becomes ready, or scroll sync is toggled + useEffect(() => { + const filePath = currentFile?.path; + + // For non-QMD files, show a placeholder and clear diagnostics + if (!isQmdFile(filePath)) { + onDiagnosticsChange([]); + setCurrentError(null); + setPreviewState('START'); + setAst(''); + onAstChange?.(null); + return; + } + + // Pass document path as-is from Automerge (e.g., "index.qmd" or "docs/index.qmd"). + updatePreview(content, filePath); + }, [content, updatePreview, wasmStatus, scrollSyncEnabled, currentFile?.path, onDiagnosticsChange]); + + // Reset preview state when file changes + useEffect(() => { + setPreviewState('START'); + setCurrentError(null); + }, [currentFile?.path]); + + return ( + <> + {wasmError && ( +
    + Failed to load WASM: {wasmError} +
    + )} + {ast && (previewState === 'GOOD' || previewState === 'ERROR_FROM_GOOD') ? ( + + ) : previewState === 'ERROR_AT_START' && currentError ? ( +
    + Render Error +
    +            {stripAnsi(currentError.message)}
    +          
    +
    + ) : ( +
    + Loading preview... +
    + )} + {/* Error overlay shown when error occurs after successful render */} + + + ); +} diff --git a/hub-client/src/components/ReactRenderer.tsx b/hub-client/src/components/ReactRenderer.tsx new file mode 100644 index 00000000..2dd1feca --- /dev/null +++ b/hub-client/src/components/ReactRenderer.tsx @@ -0,0 +1,51 @@ +import { SlideAst } from './ReactAstSlideRenderer'; + +interface ReactRendererProps { + // Pandoc AST as JSON string + astJson: string; + // Current file path for resolving relative links + currentFilePath: string; + // Callback when user navigates to a different document (with optional anchor) + onNavigateToDocument: (targetPath: string, anchor: string | null) => void; + // Optional controlled current slide index + currentSlideIndex?: number; + // Callback when slide changes (for manual navigation via arrows/buttons) + onSlideChange?: (slideIndex: number) => void; +} + +/** + * React-based renderer that displays Pandoc AST as React components. + * + * Unlike the HTML/iframe-based preview, this renders the AST directly + * as React elements, providing better integration with React's state + * management and event handling. + */ +function ReactRenderer({ + astJson, + currentFilePath, + onNavigateToDocument, + currentSlideIndex, + onSlideChange, +}: ReactRendererProps) { + return ( +
    + +
    + ); +} + +export default ReactRenderer; diff --git a/hub-client/src/components/tabs/SettingsTab.tsx b/hub-client/src/components/tabs/SettingsTab.tsx index 17c7c373..56f89f97 100644 --- a/hub-client/src/components/tabs/SettingsTab.tsx +++ b/hub-client/src/components/tabs/SettingsTab.tsx @@ -4,8 +4,11 @@ * Displays user settings: * - Scroll sync toggle * - Error overlay collapsed toggle + * - Preview screenshot button */ +import { useState } from 'react'; +import html2canvas from 'html2canvas'; import './SettingsTab.css'; import { usePreference } from '../../hooks/usePreference'; @@ -19,6 +22,47 @@ export default function SettingsTab({ onScrollSyncChange, }: SettingsTabProps) { const [errorOverlayCollapsed, setErrorOverlayCollapsed] = usePreference('errorOverlayCollapsed'); + const [isCapturing, setIsCapturing] = useState(false); + + const handleScreenshot = async () => { + try { + setIsCapturing(true); + + // Find the preview pane element + const previewPane = document.querySelector('.preview-pane') as HTMLElement; + + if (!previewPane) { + alert('Preview pane not found'); + return; + } + + // Capture the preview pane using html2canvas + const canvas = await html2canvas(previewPane, { + backgroundColor: '#ffffff', + useCORS: true, + logging: false, + }); + + // Convert canvas to blob and download + canvas.toBlob((blob) => { + if (blob) { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `preview-screenshot-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.png`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } + }, 'image/png'); + } catch (error) { + console.error('Failed to capture screenshot:', error); + alert('Failed to capture screenshot. Please try again.'); + } finally { + setIsCapturing(false); + } + }; return (
    @@ -49,6 +93,29 @@ export default function SettingsTab({ Show errors as a small indicator instead of expanded panel +
    + + + Capture the current preview as a PNG image + +
    ); diff --git a/hub-client/src/hooks/useCursorToSlide.ts b/hub-client/src/hooks/useCursorToSlide.ts new file mode 100644 index 00000000..3587f26c --- /dev/null +++ b/hub-client/src/hooks/useCursorToSlide.ts @@ -0,0 +1,95 @@ +/** + * useCursorToSlide Hook + * + * Maps cursor line numbers to slide indices for cursor-driven slide navigation. + */ + +import { useMemo } from 'react'; +import type { Symbol } from '../types/intelligence'; +import { parseSlides, type PandocAST } from '../components/ReactAstSlideRenderer'; + +interface SlideMapping { + /** The starting line (0-based) where this slide begins. */ + startLine: number; + /** The slide index. */ + slideIndex: number; +} + +/** + * Hook that provides a function to map cursor line numbers to slide indices. + * + * @param astJson - The Pandoc AST JSON string + * @param symbols - Document symbols (headers) from intelligence + * @returns A function that takes a line number (0-based) and returns the corresponding slide index + */ +export function useCursorToSlide( + astJson: string | null, + symbols: Symbol[] +): (line: number) => number { + const slideMapping = useMemo((): SlideMapping[] => { + if (!astJson || symbols.length === 0) { + return []; + } + + try { + const ast: PandocAST = JSON.parse(astJson); + const slides = parseSlides(ast); + const mappings: SlideMapping[] = []; + + // Filter symbols to only headers (those that create slides) + // Headers use SymbolKind 'string' in our LSP + // The symbols array contains top-level symbols only (h1/h2), with h3+ nested as children + const slideHeaders = symbols.filter(s => s.kind === 'string'); + + // Match slides to headers by index (structural position) + let headerIndex = 0; + for (let slideIndex = 0; slideIndex < slides.length; slideIndex++) { + const slide = slides[slideIndex]; + + if (slide.type === 'title') { + // Title slide starts at line 0 + mappings.push({ startLine: 0, slideIndex }); + continue; + } + + // Match this content slide to the next header by index + if (headerIndex < slideHeaders.length) { + const header = slideHeaders[headerIndex]; + mappings.push({ + startLine: header.range.start.line, + slideIndex, + }); + headerIndex++; + } + } + + // Sort by line number (ascending) - should already be sorted, but just in case + mappings.sort((a, b) => a.startLine - b.startLine); + + return mappings; + } catch (err) { + console.error('Failed to build cursor-to-slide mapping:', err); + return []; + } + }, [astJson, symbols]); + + // Return a function that maps a line number to a slide index + return useMemo(() => { + return (line: number): number => { + if (slideMapping.length === 0) { + return 0; + } + + // Find the last slide that starts at or before this line + let slideIndex = 0; + for (let i = slideMapping.length - 1; i >= 0; i--) { + if (slideMapping[i].startLine <= line) { + slideIndex = slideMapping[i].slideIndex; + break; + } + } + + return slideIndex; + }; + }, [slideMapping]); +} diff --git a/hub-client/src/hooks/useSectionThumbnails.ts b/hub-client/src/hooks/useSectionThumbnails.ts new file mode 100644 index 00000000..3effff39 --- /dev/null +++ b/hub-client/src/hooks/useSectionThumbnails.ts @@ -0,0 +1,134 @@ +/** + * useSectionThumbnails Hook + * + * Generates thumbnail images for document sections (h1 headers + content). + * Each thumbnail is 128x64 pixels. + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import html2canvas from 'html2canvas'; +import type { Symbol } from '../types/intelligence'; + +/** + * Map from symbol line number to thumbnail data URL. + */ +export type ThumbnailMap = Map; + +interface UseSectionThumbnailsOptions { + /** Document symbols (headers) to generate thumbnails for. */ + symbols: Symbol[]; + /** Whether the preview is ready for thumbnail capture. */ + previewReady: boolean; + /** Trigger value that changes when preview content updates. */ + contentVersion: number; +} + +/** + * Generate thumbnails for document sections. + * + * A section is defined as an h1 header plus all content until the next h1. + * Thumbnails are captured at 128x64 pixels. + */ +export function useSectionThumbnails({ + symbols, + previewReady, + contentVersion, +}: UseSectionThumbnailsOptions): ThumbnailMap { + const [thumbnails, setThumbnails] = useState(new Map()); + const captureInProgressRef = useRef(false); + + const captureThumbnails = useCallback(async () => { + // Avoid overlapping capture operations + if (captureInProgressRef.current) return; + if (!previewReady) return; + if (symbols.length === 0) return; + + captureInProgressRef.current = true; + + try { + const newThumbnails = new Map(); + + // Find h1 headers in the preview pane + const previewPane = document.querySelector('.preview-pane'); + if (!previewPane) { + console.warn('Preview pane not found for thumbnail capture'); + return; + } + + // Get all h1 elements + const h1Elements = previewPane.querySelectorAll('h1'); + + // Match symbols to h1 elements + // We'll use the symbol's line number as the key + const h1Symbols = symbols.filter((s) => s.kind === 'string'); // Headers use 'string' kind + + for (let i = 0; i < h1Elements.length; i++) { + const h1 = h1Elements[i] as HTMLElement; + const symbol = h1Symbols[i]; + + if (!symbol) continue; + + // Create a temporary container for this section + const container = document.createElement('div'); + container.style.position = 'absolute'; + container.style.left = '-10000px'; + container.style.top = '-10000px'; + container.style.width = '800px'; // Match typical preview width + container.style.backgroundColor = 'white'; + container.style.padding = '20px'; + document.body.appendChild(container); + + // Clone the h1 and add it + const h1Clone = h1.cloneNode(true) as HTMLElement; + container.appendChild(h1Clone); + + // Find all siblings until the next h1 + let sibling = h1.nextElementSibling; + while (sibling && sibling.tagName !== 'H1') { + const siblingClone = sibling.cloneNode(true) as HTMLElement; + container.appendChild(siblingClone); + sibling = sibling.nextElementSibling; + } + + try { + // Capture the container + const canvas = await html2canvas(container, { + backgroundColor: '#ffffff', + useCORS: true, + logging: false, + scale: 0.5, // Reduce resolution for performance + width: 800, + windowWidth: 800, + }); + + // Resize to 128x64 + const thumbnailCanvas = document.createElement('canvas'); + thumbnailCanvas.width = 128; + thumbnailCanvas.height = 64; + const ctx = thumbnailCanvas.getContext('2d'); + if (ctx) { + ctx.drawImage(canvas, 0, 0, 128, 64); + const dataUrl = thumbnailCanvas.toDataURL('image/png'); + newThumbnails.set(symbol.range.start.line, dataUrl); + } + } catch (error) { + console.error('Failed to capture section thumbnail:', error); + } finally { + // Clean up the temporary container + document.body.removeChild(container); + } + } + + setThumbnails(newThumbnails); + } finally { + captureInProgressRef.current = false; + } + }, [symbols, previewReady]); + + // Capture thumbnails when content changes + useEffect(() => { + captureThumbnails(); + }, [contentVersion, captureThumbnails]); + + return thumbnails; +} diff --git a/hub-client/src/hooks/useSlideThumbnails.tsx b/hub-client/src/hooks/useSlideThumbnails.tsx new file mode 100644 index 00000000..c64d3d88 --- /dev/null +++ b/hub-client/src/hooks/useSlideThumbnails.tsx @@ -0,0 +1,176 @@ +/** + * useSlideThumbnails Hook + * + * Generates thumbnail images for presentation slides. + * Each thumbnail is 128x64 pixels (maintaining 3:2 aspect ratio). + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import ReactDOM from 'react-dom/client'; +import html2canvas from 'html2canvas'; +import type { Symbol } from '../types/intelligence'; +import { parseSlides, renderSlide, type PandocAST } from '../components/ReactAstSlideRenderer'; + +/** + * Map from symbol line number to thumbnail data URL. + */ +export type ThumbnailMap = Map; + +interface UseSlideThumbnailsOptions { + /** Pandoc AST JSON string. */ + astJson: string | null; + /** Current file path for resolving relative image paths. */ + currentFilePath: string; + /** Document symbols (headers) to generate thumbnails for. */ + symbols: Symbol[]; + /** Whether the preview is ready for thumbnail capture. */ + previewReady: boolean; + /** Trigger value that changes when preview content updates. */ + contentVersion: number; +} + + +/** + * Generate thumbnails for presentation slides. + * + * Renders each slide off-screen and captures it as a thumbnail. + * Thumbnails are captured at 128x64 pixels (3:2 aspect ratio). + */ +export function useSlideThumbnails({ + astJson, + currentFilePath, + symbols, + previewReady, + contentVersion, +}: UseSlideThumbnailsOptions): ThumbnailMap { + const [thumbnails, setThumbnails] = useState(new Map()); + const captureInProgressRef = useRef(false); + const debounceTimeoutRef = useRef(null); + + const captureThumbnails = useCallback(async () => { + // Avoid overlapping capture operations + if (captureInProgressRef.current) return; + if (!previewReady) return; + if (!astJson) return; + if (symbols.length === 0) return; + + captureInProgressRef.current = true; + + try { + const newThumbnails = new Map(); + + // Parse AST to get slides + let ast: PandocAST; + try { + ast = JSON.parse(astJson); + } catch (err) { + console.error('Failed to parse AST for thumbnails:', err); + return; + } + + const slides = parseSlides(ast); + + // Filter symbols to only headers (those that create slides) + // Headers use SymbolKind 'string' in our LSP + // The symbols array contains top-level symbols only (h1/h2), with h3+ nested as children + const slideHeaders = symbols.filter(s => s.kind === 'string'); + + // Match slides to headers by index (structural position) + let headerIndex = 0; + for (let slideIndex = 0; slideIndex < slides.length; slideIndex++) { + const slide = slides[slideIndex]; + + // Skip title slides (they don't correspond to document sections) + if (slide.type === 'title') { + continue; + } + + // Match this content slide to the next header by index + if (headerIndex >= slideHeaders.length) { + continue; + } + + const matchingSymbol = slideHeaders[headerIndex]; + headerIndex++; + + // Create a temporary container for rendering this slide + const container = document.createElement('div'); + container.style.position = 'absolute'; + container.style.left = '-10000px'; + container.style.top = '-10000px'; + container.style.width = '1050px'; + container.style.height = '700px'; + container.style.backgroundColor = 'white'; + container.style.boxShadow = '0 0 30px rgba(0,0,0,0.5)'; + document.body.appendChild(container); + + try { + // Render the slide content using React + const root = ReactDOM.createRoot(container); + + // Wait for rendering and capture + await new Promise((resolve) => { + root.render(
    {renderSlide(slide, currentFilePath)}
    ); + + // Give React time to render + setTimeout(async () => { + try { + // Capture the container + const canvas = await html2canvas(container, { + backgroundColor: '#ffffff', + useCORS: true, + logging: false, + scale: 0.3, // Lower scale for performance + width: 1050, + height: 700, + }); + + // Resize to thumbnail size (128x64 maintains 3:2 ratio) + const thumbnailCanvas = document.createElement('canvas'); + thumbnailCanvas.width = 128; + thumbnailCanvas.height = 64; + const ctx = thumbnailCanvas.getContext('2d'); + if (ctx) { + ctx.drawImage(canvas, 0, 0, 128, 64); + const dataUrl = thumbnailCanvas.toDataURL('image/png'); + newThumbnails.set(matchingSymbol.range.start.line, dataUrl); + } + } catch (error) { + console.error(`Failed to capture thumbnail for slide ${slideIndex} ("${matchingSymbol.name}"):`, error); + } finally { + root.unmount(); + resolve(); + } + }, 50); // Short delay for React to render + }); + } finally { + // Clean up the temporary container + document.body.removeChild(container); + } + } + + setThumbnails(newThumbnails); + } finally { + captureInProgressRef.current = false; + } + }, [astJson, currentFilePath, symbols, previewReady]); + + // Debounced capture thumbnails when content changes + useEffect(() => { + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } + + debounceTimeoutRef.current = window.setTimeout(() => { + captureThumbnails(); + }, 100); + + return () => { + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } + }; + }, [contentVersion, captureThumbnails]); + + return thumbnails; +} diff --git a/hub-client/src/index.css b/hub-client/src/index.css index 34d4dea0..d9f09243 100644 --- a/hub-client/src/index.css +++ b/hub-client/src/index.css @@ -4,7 +4,8 @@ font-weight: 400; color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); + /* color: rgba(255, 255, 255, 0.87); */ + color: black; background-color: #242424; font-synthesis: none; @@ -18,6 +19,7 @@ a { color: #646cff; text-decoration: inherit; } + a:hover { color: #535bf2; } @@ -48,9 +50,11 @@ button { cursor: pointer; transition: border-color 0.25s; } + button:hover { border-color: #646cff; } + button:focus, button:focus-visible { outline: 4px auto -webkit-focus-ring-color; @@ -61,10 +65,12 @@ button:focus-visible { color: #213547; background-color: #ffffff; } + a:hover { color: #747bff; } + button { background-color: #f9f9f9; } -} +} \ No newline at end of file diff --git a/hub-client/src/services/wasmRenderer.ts b/hub-client/src/services/wasmRenderer.ts index a7b7b698..72945388 100644 --- a/hub-client/src/services/wasmRenderer.ts +++ b/hub-client/src/services/wasmRenderer.ts @@ -36,6 +36,9 @@ interface WasmModuleExtended { get_builtin_template: (name: string) => string; get_project_choices: () => string; create_project: (choiceId: string, title: string) => Promise; + parse_qmd_to_ast: (content: string) => Promise; + write_qmd: (astJson: string) => Promise; + convert: (document: string, inputFormat: string, outputFormat: string) => Promise; lsp_analyze_document: (path: string) => string; lsp_get_symbols: (path: string) => string; lsp_get_folding_ranges: (path: string) => string; @@ -316,6 +319,216 @@ export function getBuiltinTemplate(name: string): string { return wasm.get_builtin_template(name); } +/** + * Result of parsing QMD content to AST. + */ +export interface ParseResult { + success: boolean; + ast: string; + error?: string; + /** Structured error diagnostics with line/column information for Monaco. */ + diagnostics?: Diagnostic[]; + /** Structured warning diagnostics with line/column information for Monaco. */ + warnings?: Diagnostic[]; +} + +/** + * Result of writing AST to QMD format. + */ +export interface WriteQmdResult { + success: boolean; + qmd: string; + error?: string; +} + +/** + * Result of converting between formats. + */ +export interface ConvertResult { + success: boolean; + output: string; + error?: string; +} + +/** + * Parse QMD content to Pandoc AST JSON, handling errors gracefully. + * + * This function parses QMD markdown into a Pandoc AST representation, + * which can be used for programmatic manipulation, analysis, or rendering + * with custom React components. + * + * Returns structured diagnostics with source locations that can be + * converted to Monaco editor markers using diagnosticsToMarkers(). + * + * **Example AST Structure:** + * ```json + * { + * "pandoc-api-version": [1, 23, 1], + * "meta": {}, + * "blocks": [ + * { + * "t": "Header", + * "c": [1, ["id", ["class"], [["key", "value"]]], [{"t": "Str", "c": "text"}]] + * }, + * { + * "t": "Para", + * "c": [{"t": "Str", "c": "Paragraph text."}] + * } + * ] + * } + * ``` + * + * @param qmdContent - QMD source text to parse + * @returns Parse result with AST JSON string or error information + */ +export async function parseQmdToAst( + qmdContent: string +): Promise { + try { + await initWasm(); + const wasm = getWasm(); + const responseJson = await wasm.parse_qmd_to_ast(qmdContent); + + const response: ParseResult = JSON.parse(responseJson); + + if (response.success) { + return { + ast: response.ast || '{}', + success: true, + warnings: response.warnings, + }; + } else { + // Extract error message + const errorMsg = response.error || 'Unknown parse error'; + + return { + ast: '', + success: false, + error: errorMsg, + diagnostics: response.diagnostics, + warnings: response.warnings, + }; + } + } catch (err) { + console.error('Parse error:', err); + return { + ast: '', + success: false, + error: err instanceof Error ? err.message : JSON.stringify(err), + }; + } +} + +/** + * Convert Pandoc AST JSON back to QMD format. + * + * This function takes a Pandoc AST represented as a JSON string and + * converts it back to QMD markdown format. + * + * @param astJson - Pandoc AST as JSON string + * @returns Write result with QMD string or error information + * + * @example + * ```typescript + * const ast = '{"pandoc-api-version":[1,23,1],"meta":{},"blocks":[...]}'; + * const result = await writeQmd(ast); + * if (result.success) { + * console.log("QMD:", result.qmd); + * } + * ``` + */ +export async function writeQmd(astJson: string): Promise { + try { + await initWasm(); + const wasm = getWasm(); + const responseJson = await wasm.write_qmd(astJson); + + // The response reuses AstResponse structure, but with "qmd" in the "ast" field + const response: { success: boolean; ast?: string; error?: string } = JSON.parse(responseJson); + + if (response.success) { + return { + qmd: response.ast || '', + success: true, + }; + } else { + return { + qmd: '', + success: false, + error: response.error || 'Unknown write error', + }; + } + } catch (err) { + console.error('Write QMD error:', err); + return { + qmd: '', + success: false, + error: err instanceof Error ? err.message : JSON.stringify(err), + }; + } +} + +/** + * Convert between document formats (QMD <-> JSON). + * + * This function provides generic format conversion capabilities, + * allowing you to convert between QMD and Pandoc AST JSON. + * + * @param document - Input document content + * @param inputFormat - Input format: "qmd" or "json" + * @param outputFormat - Output format: "qmd" or "json" + * @returns Convert result with output string or error information + * + * @example + * ```typescript + * // Convert QMD to JSON + * const jsonResult = await convert(qmdContent, "qmd", "json"); + * if (jsonResult.success) { + * const ast = JSON.parse(jsonResult.output); + * } + * + * // Convert JSON back to QMD + * const qmdResult = await convert(astJson, "json", "qmd"); + * if (qmdResult.success) { + * console.log("QMD:", qmdResult.output); + * } + * ``` + */ +export async function convert( + document: string, + inputFormat: 'qmd' | 'json', + outputFormat: 'qmd' | 'json' +): Promise { + try { + await initWasm(); + const wasm = getWasm(); + const responseJson = await wasm.convert(document, inputFormat, outputFormat); + + // The response reuses AstResponse structure, but with output in the "ast" field + const response: { success: boolean; ast?: string; error?: string } = JSON.parse(responseJson); + + if (response.success) { + return { + output: response.ast || '', + success: true, + }; + } else { + return { + output: '', + success: false, + error: response.error || 'Unknown conversion error', + }; + } + } catch (err) { + console.error('Convert error:', err); + return { + output: '', + success: false, + error: err instanceof Error ? err.message : JSON.stringify(err), + }; + } +} + // ============================================================================ // High-Level API // ============================================================================ @@ -443,8 +656,8 @@ export async function renderToHtml( // Use the options-aware render function if options are specified const result: RenderResponse = options.sourceLocation ? await renderQmdContentWithOptions(qmdContent, htmlTemplateBundle || '', { - sourceLocation: options.sourceLocation, - }) + sourceLocation: options.sourceLocation, + }) : await renderQmdContent(qmdContent, htmlTemplateBundle || ''); console.log('[renderToHtml] HTML has data-loc:', result.html?.includes('data-loc')); diff --git a/package-lock.json b/package-lock.json index c76e650b..0de13081 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,10 @@ "q2-demos/*" ], "dependencies": { + "@types/katex": "^0.16.8", "@types/morphdom": "^2.3.0", + "html2canvas": "^1.4.1", + "katex": "^0.16.28", "morphdom": "^2.7.8" } }, @@ -279,8 +282,7 @@ "hub-client/node_modules/@types/trusted-types": { "version": "2.0.7", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "hub-client/node_modules/@typescript-eslint/eslint-plugin": { "version": "8.50.1", @@ -321,6 +323,7 @@ "version": "8.50.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/types": "8.50.1", @@ -527,6 +530,7 @@ "version": "8.15.0", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -625,7 +629,6 @@ "hub-client/node_modules/dompurify": { "version": "3.2.7", "license": "(MPL-2.0 OR Apache-2.0)", - "peer": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -658,6 +661,7 @@ "version": "9.39.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -1049,7 +1053,6 @@ "hub-client/node_modules/marked": { "version": "14.0.0", "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -1367,6 +1370,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1834,6 +1838,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -1857,6 +1862,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1878,6 +1884,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -2776,9 +2783,9 @@ "link": true }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.2", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", - "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", "dev": true, "license": "MIT" }, @@ -3234,8 +3241,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3307,6 +3313,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "license": "MIT" + }, "node_modules/@types/morphdom": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@types/morphdom/-/morphdom-2.3.0.tgz", @@ -3324,9 +3336,9 @@ } }, "node_modules/@types/react": { - "version": "19.2.13", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz", - "integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==", + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", "dependencies": { @@ -3354,16 +3366,16 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.3.tgz", - "integrity": "sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", + "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-rc.2", + "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, @@ -3542,7 +3554,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -3594,14 +3605,26 @@ "integrity": "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==", "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/brace-expansion": { @@ -3634,6 +3657,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3678,9 +3702,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001769", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", - "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", + "version": "1.0.30001774", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", + "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", "dev": true, "funding": [ { @@ -3784,6 +3808,15 @@ "dev": true, "license": "MIT" }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -3806,6 +3839,15 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -3907,8 +3949,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/eastasianwidth": { "version": "0.2.0", @@ -3918,9 +3959,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.286", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", - "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", "dev": true, "license": "ISC" }, @@ -4181,6 +4222,19 @@ "dev": true, "license": "MIT" }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -4440,6 +4494,22 @@ "node": ">=6" } }, + "node_modules/katex": { + "version": "0.16.28", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.28.tgz", + "integrity": "sha512-YHzO7721WbmAL6Ov1uzN/l5mY5WWWhJBSW+jq4tkfZfsxmo1hu6frS0EOswvjBUnWE6NtjEs48SFn5CQESRLZg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -4460,7 +4530,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -4782,7 +4851,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -4807,6 +4875,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4816,6 +4885,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4828,8 +4898,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-refresh": { "version": "0.18.0", @@ -5214,6 +5283,15 @@ "dev": true, "license": "MIT" }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -5355,6 +5433,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5401,6 +5480,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", @@ -5420,6 +5508,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -5528,6 +5617,7 @@ "integrity": "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.17", "@vitest/mocker": "4.0.17", @@ -5800,6 +5890,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -5855,6 +5946,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 4651d20f..79be002b 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,10 @@ "typecheck": "npm run typecheck --workspaces --if-present" }, "dependencies": { + "@types/katex": "^0.16.8", "@types/morphdom": "^2.3.0", + "html2canvas": "^1.4.1", + "katex": "^0.16.28", "morphdom": "^2.7.8" } } From aa31693b8a0efe7cc5daf38d4d37e8774b0518d7 Mon Sep 17 00:00:00 2001 From: elliot Date: Wed, 25 Feb 2026 14:17:59 -0500 Subject: [PATCH 2/2] Add PreviewRouter to choose between slides renderer and normal preview --- hub-client/src/components/Editor.css | 6 +- hub-client/src/components/Editor.tsx | 10 +- hub-client/src/components/Preview.tsx | 34 +++--- hub-client/src/components/PreviewRouter.tsx | 112 ++++++++++++++++++++ hub-client/src/components/ReactPreview.tsx | 46 ++++---- 5 files changed, 163 insertions(+), 45 deletions(-) create mode 100644 hub-client/src/components/PreviewRouter.tsx diff --git a/hub-client/src/components/Editor.css b/hub-client/src/components/Editor.css index c09266d5..813d1107 100644 --- a/hub-client/src/components/Editor.css +++ b/hub-client/src/components/Editor.css @@ -279,7 +279,6 @@ .preview-pane { background: #fff; position: relative; - overflow: scroll; } .preview-pane.fullscreen { @@ -424,7 +423,8 @@ .preview-error-content { padding: 12px; - max-height: calc(20 * 1.4em); /* ~20 lines */ + max-height: calc(20 * 1.4em); + /* ~20 lines */ overflow-y: auto; } @@ -460,4 +460,4 @@ .preview-error-diagnostics .diagnostic-problem { color: #a5b4fc; -} +} \ No newline at end of file diff --git a/hub-client/src/components/Editor.tsx b/hub-client/src/components/Editor.tsx index 40858599..a6e63053 100644 --- a/hub-client/src/components/Editor.tsx +++ b/hub-client/src/components/Editor.tsx @@ -36,7 +36,7 @@ import ViewToggleControl from './ViewToggleControl'; import { useViewMode } from './ViewModeContext'; import MarkdownSummary from './MarkdownSummary'; import './Editor.css'; -import ReactPreview from './ReactPreview'; +import PreviewRouter from './PreviewRouter'; interface Props { project: ProjectEntry; @@ -243,6 +243,11 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC setContentVersion(prev => prev + 1); }, []); + // Callback for preview to register scroll-to-line function + const handleRegisterScrollToLine = useCallback((fn: (line: number) => void) => { + previewScrollToLineRef.current = fn; + }, []); + // Update document title based on current file and project useEffect(() => { if (currentFile) { @@ -846,7 +851,7 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC ✕ )} - +
    {wasmError && (
    Failed to load WASM: {wasmError}
    )} -
    - - {/* Error overlay shown when error occurs after successful render */} - -
    - + + {/* Error overlay shown when error occurs after successful render */} + +
    ); } diff --git a/hub-client/src/components/PreviewRouter.tsx b/hub-client/src/components/PreviewRouter.tsx new file mode 100644 index 00000000..ecefb0a5 --- /dev/null +++ b/hub-client/src/components/PreviewRouter.tsx @@ -0,0 +1,112 @@ +import { useState, useEffect } from 'react'; +import type * as Monaco from 'monaco-editor'; +import type { FileEntry } from '../types/project'; +import type { Diagnostic } from '../types/diagnostic'; +import { parseQmdToAst, isWasmReady, initWasm } from '../services/wasmRenderer'; +import Preview from './Preview'; +import ReactPreview from './ReactPreview'; + +interface PreviewRouterProps { + content: string; + currentFile: FileEntry | null; + files: FileEntry[]; + scrollSyncEnabled: boolean; + editorRef: React.RefObject; + editorReady: boolean; + editorHasFocusRef: React.RefObject; + onFileChange: (file: FileEntry, anchor?: string) => void; + onOpenNewFileDialog: (initialFilename: string) => void; + onDiagnosticsChange: (diagnostics: Diagnostic[]) => void; + onWasmStatusChange?: (status: 'loading' | 'ready' | 'error', error: string | null) => void; + onRegisterScrollToLine?: (fn: (line: number) => void) => void; + onAstChange?: (astJson: string | null) => void; + currentSlideIndex?: number; + onSlideChange?: (slideIndex: number) => void; +} + +/** + * Check if the parsed AST metadata contains format: q2-slides + */ +function hasQ2SlidesFormat(astJson: string): boolean { + try { + const ast = JSON.parse(astJson); + console.log('YOOOO', ast?.meta?.format?.c?.[0]?.c) + return 'q2-slides' === ast?.meta?.format?.c?.[0]?.c; + } catch (err) { + console.error('[PreviewRouter] Failed to parse AST:', err); + return false; + } +} + +/** + * Router component that selects between Preview and ReactPreview based on document format. + * + * - If format: q2-slides is present in the YAML frontmatter, use ReactPreview (for slides) + * - Otherwise, use the normal Preview component (for regular HTML rendering) + */ +export default function PreviewRouter(props: PreviewRouterProps) { + const [useReactPreview, setUseReactPreview] = useState(false); + const [isChecking, setIsChecking] = useState(true); + + // Check the format whenever content changes + useEffect(() => { + let cancelled = false; + + async function checkFormat() { + setIsChecking(true); + + try { + // Ensure WASM is ready + if (!isWasmReady()) { + await initWasm(); + } + + // Parse the QMD to AST to check metadata + const result = await parseQmdToAst(props.content); + + if (cancelled) return; + + if (result.success) { + const hasSlides = hasQ2SlidesFormat(result.ast); + setUseReactPreview(hasSlides); + } else { + // On parse error, default to normal Preview + setUseReactPreview(false); + } + } catch (err) { + console.error('[PreviewRouter] Error checking format:', err); + if (!cancelled) { + setUseReactPreview(false); + } + } finally { + if (!cancelled) { + setIsChecking(false); + } + } + } + + checkFormat(); + + return () => { + cancelled = true; + }; + }, [props.content, props.currentFile?.path]); + + // Show loading state while checking format + if (isChecking) { + return ( +
    + Loading preview... +
    + ); + } + + // Render the appropriate preview component + if (useReactPreview) { + // ReactPreview doesn't use onRegisterScrollToLine, so we omit it + const { onRegisterScrollToLine, ...reactPreviewProps } = props; + return ; + } else { + return ; + } +} diff --git a/hub-client/src/components/ReactPreview.tsx b/hub-client/src/components/ReactPreview.tsx index c870ad1c..f9fdb6a5 100644 --- a/hub-client/src/components/ReactPreview.tsx +++ b/hub-client/src/components/ReactPreview.tsx @@ -246,37 +246,39 @@ export default function ReactPreview({ }, [currentFile?.path]); return ( - <> +
    {wasmError && (
    Failed to load WASM: {wasmError}
    )} - {ast && (previewState === 'GOOD' || previewState === 'ERROR_FROM_GOOD') ? ( - - ) : previewState === 'ERROR_AT_START' && currentError ? ( -
    - Render Error -
    -            {stripAnsi(currentError.message)}
    -          
    -
    - ) : ( -
    - Loading preview... -
    - )} +
    + {ast && (previewState === 'GOOD' || previewState === 'ERROR_FROM_GOOD') ? ( + + ) : previewState === 'ERROR_AT_START' && currentError ? ( +
    + Render Error +
    +              {stripAnsi(currentError.message)}
    +            
    +
    + ) : ( +
    + Loading preview... +
    + )} +
    {/* Error overlay shown when error occurs after successful render */} - +
    ); }