diff --git a/docs/explanatory_components.yml b/docs/explanatory_components.yml index 8cac472a..79b9b2ec 100644 --- a/docs/explanatory_components.yml +++ b/docs/explanatory_components.yml @@ -45,6 +45,229 @@ components: [Ask Rigo how to create a function in Python](https://4geeks.com/ask?query=help-me-understand-how-to-create-a-function-in-python-in-the-simplest-possible-way) + - name: code_comparison + intendedUse: + - comparing code versions + - showing bug fixes or improvements + - demonstrating different algorithmic approaches + description: >- + Compare two code blocks side-by-side to show improvements, bug fixes, refactoring, or different approaches to solve the same problem. This is the most common comparison use case. + goodFor: + - showing before/after code improvements + - comparing simple vs optimized implementations + - demonstrating bug fixes with validation + - contrasting iterative vs functional approaches + - showing different coding patterns or best practices + whenToUse: Use when comparing two versions of code enhances learning. Perfect for teaching refactoring, debugging, optimization, or comparing different solutions to the same problem. For HTML/CSS comparisons, prefer html_css_comparison which offers both visual and code comparison modes. + avoid: + - comparing unrelated code snippets + - very long code blocks (keep focused on the key differences) + - more than one concept per comparison + - HTML code (use html_css_comparison instead for better flexibility with raw/rendered modes) + rules: + - CRITICAL - NEVER use this for HTML code, always use html_css_comparison component instead + - CRITICAL - Must use exactly "---SEPARATOR---" on its own line to divide the two code blocks + - type="code" is REQUIRED + - language attribute is REQUIRED (e.g. language="python", language="javascript") + - NEVER set language="html" - use html_css_comparison component instead + - Use descriptive leftLabel and rightLabel to guide the student + - Keep both code blocks similar in length for best visual results + metadata: + - type - Must be "code" + - language - REQUIRED - Programming language (python, javascript, java, etc.) NEVER "html" + - leftLabel - Recommended - Label for left panel (e.g. "Basic Version", "With Bug") + - rightLabel - Recommended - Label for right panel (e.g. "Optimized", "Fixed Version") + example: | + ```comparison type="code" language="python" leftLabel="Simple Version" rightLabel="With Validation" + def factorial(n): + result = 1 + for i in range(1, n + 1): + result *= i + return result + + ---SEPARATOR--- + + def factorial(n): + """Calculate factorial with validation.""" + if not isinstance(n, int) or n < 0: + raise ValueError("Must be a positive integer") + if n == 0 or n == 1: + return 1 + return n * factorial(n - 1) + ``` + --- Second Example --- + + ```comparison type="code" language="javascript" leftLabel="With Bug" rightLabel="Fixed" + function greet(name) { + console.log("Hello " + name) + } + greet() // Hello undefined + + ---SEPARATOR--- + + function greet(name) { + if (!name) { + console.log("Hello, stranger!"); + return; + } + console.log("Hello " + name); + } + greet() // Hello, stranger! + ``` + + - name: html_css_comparison + intendedUse: + - CSS styling comparisons + - visual design improvements + - HTML structure and semantics comparisons + - demonstrating CSS techniques + description: >- + Compare two HTML/CSS implementations side-by-side. PRIMARY USE: comparing CSS styling, layout techniques, and visual designs. This is THE component for teaching CSS concepts through visual or code comparisons. Supports both interactive slider view (for visual CSS changes) and side-by-side code view (for CSS structure/syntax). ALWAYS use this for any HTML/CSS content, never use code_comparison for HTML. + goodFor: + - CSS property comparisons (margin vs padding, flexbox vs grid) + - showing before/after styling improvements (basic CSS vs modern CSS) + - comparing CSS layout techniques (float vs flexbox, flexbox vs grid) + - demonstrating responsive design changes (mobile-first vs desktop-first) + - visual UI enhancements and styling improvements (colors, shadows, animations) + - CSS architecture patterns (BEM vs utility-first, CSS variables) + - comparing HTML structure and element placement + - showing semantic HTML differences (accessibility, attributes, form structure) + whenToUse: ALWAYS use this component for ANY HTML/CSS comparison in CSS courses, html courses, or UI design courses. By default shows an interactive slider for visual CSS comparisons. For code-focused comparisons (CSS syntax, selectors, structure), add layout="side-by-side" with leftInitialMode="raw" and rightInitialMode="raw". This is mandatory for HTML content - never use code_comparison for HTML/CSS. + avoid: + - comparing completely different page structures (focus on similar layouts) + rules: + - CRITICAL - Must use exactly "---SEPARATOR---" on its own line to divide the two HTML blocks + - type="html" is REQUIRED + - Use descriptive leftLabel and rightLabel to describe each version + - Both HTML blocks should represent comparable designs for best effect + - Use layout="side-by-side" with leftInitialMode="raw" and rightInitialMode="raw" when comparing code structure, not visual results + - Can force layout="side-by-side" if you prefer code comparison over visual slider + metadata: + - type - Must be "html" + - leftLabel - Recommended - Label for left panel (e.g. "Basic Design", "Flexbox") + - rightLabel - Recommended - Label for right panel (e.g. "Enhanced", "CSS Grid") + - layout - Optional - Use "side-by-side" to force code comparison instead of slider + - leftInitialMode - Optional - Set to "raw" to start left panel in code mode (defaults to "rendered") + - rightInitialMode - Optional - Set to "raw" to start right panel in code mode (defaults to "rendered") + example: | + ```comparison type="html" leftLabel="Basic Styles" rightLabel="Enhanced Design" + + + + + + +

Original Version

+

Basic styling applied.

+ + + + ---SEPARATOR--- + + + + + + + +

Enhanced Version

+

Modern gradient and improved styling.

+ + + + ``` + --- Second Example --- + + ```comparison type="html" layout="side-by-side" leftLabel="Without Accessibility" rightLabel="With ARIA Attributes" leftInitialMode="raw" rightInitialMode="raw" + + + ---SEPARATOR--- + + + ``` + + - name: code_vs_rendered + intendedUse: + - teaching markup syntax (Markdown, Mermaid) + - showing source code and its rendered output + description: >- + Show source code on one side and its rendered output on the other. Perfect for teaching Markdown or Mermaid syntax by displaying the raw code and final result simultaneously. + goodFor: + - teaching Markdown syntax + - teaching Mermaid diagram syntax + - showing how markup translates to visual output + whenToUse: Use when teaching syntax where seeing both the source and rendered result together enhances understanding. Set different initial modes for each panel and disable sync. + avoid: + - when simple code-only comparison is sufficient + - for HTML (where preview is already obvious) + rules: + - CRITICAL - Must use exactly "---SEPARATOR---" on its own line (can use same content on both sides) + - type must be "mermaid" or "markdown" + - MUST set leftInitialMode="raw" and rightInitialMode="rendered" + - MUST set syncRenderToggle="false" to allow independent panel control + - Both sides typically contain the SAME content (shown differently) + metadata: + - type - Must be "mermaid" or "markdown" + - leftLabel - Recommended - Usually "Source Code" or "Markdown Code" + - rightLabel - Recommended - Usually "Rendered Output" or "Result" + - leftInitialMode - REQUIRED - Must be "raw" + - rightInitialMode - REQUIRED - Must be "rendered" + - syncRenderToggle - REQUIRED - Must be "false" + example: | + ```comparison type="mermaid" leftLabel="Mermaid Code" rightLabel="Rendered Diagram" leftInitialMode="raw" rightInitialMode="rendered" syncRenderToggle="false" + sequenceDiagram + participant User + participant Frontend + participant Backend + + User->>Frontend: Request Data + Frontend->>Backend: API Call + Backend-->>Frontend: Response + Frontend-->>User: Display Data + + ---SEPARATOR--- + + sequenceDiagram + participant User + participant Frontend + participant Backend + + User->>Frontend: Request Data + Frontend->>Backend: API Call + Backend-->>Frontend: Response + Frontend-->>User: Display Data + ``` + - name: technical_diagram intendedUse: - technical explanation with structural relationships diff --git a/src/components/composites/Comparison/Comparison.tsx b/src/components/composites/Comparison/Comparison.tsx new file mode 100644 index 00000000..3e2385b6 --- /dev/null +++ b/src/components/composites/Comparison/Comparison.tsx @@ -0,0 +1,481 @@ +import React, { useState, useRef, useEffect, useCallback } from "react"; +import { Preview } from "../Preview/Preview"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { atomDark } from "react-syntax-highlighter/dist/esm/styles/prism"; +import MermaidRenderer from "../MermaidRenderer/MermaidRenderer"; +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import remarkMath from "remark-math"; +import rehypeKatex from "rehype-katex"; +import { useTranslation } from "react-i18next"; +import SwitchComponent from "../../ui/switch"; +import type { ComparisonProps, ComparisonItem, ContentMode, ContentType, ComparisonLayout } from "./types"; + +// Internal component to render content based on type and mode +const ContentRenderer: React.FC<{ + item: ComparisonItem; + currentMode: ContentMode; +}> = ({ item, currentMode }) => { + // If custom render is provided, use it + if (item.customRender) { + return <>{item.customRender(item.content, currentMode)}; + } + + // Handle different content types + switch (item.type) { + case "html": + if (currentMode === "rendered") { + return ( + {}} useIframe={true} /> + ); + } else { + return ( +
+ + {String(item.content)} + +
+ ); + } + + case "text": + return ( +
+
+            {String(item.content)}
+          
+
+ ); + + case "code": + return ( +
+ + {String(item.content)} + +
+ ); + + case "mermaid": + if (currentMode === "rendered") { + return ( +
+ +
+ ); + } else { + return ( +
+ + {String(item.content)} + +
+ ); + } + + case "markdown": + if (currentMode === "rendered") { + return ( +
+ + {String(item.content)} + +
+ ); + } else { + return ( +
+ + {String(item.content)} + +
+ ); + } + + case "image": + return ( +
+ {item.label +
+ ); + + default: + return ( +
+ Unknown content type: {item.type} +
+ ); + } +}; + +// Determine default layout based on content types +const determineLayout = (left: ComparisonItem, right: ComparisonItem): ComparisonLayout => { + // Use slider for visual content (HTML and images) + if (left.type === "html" || right.type === "html" || + left.type === "image" || right.type === "image") { + return "slider"; + } + + // Use side-by-side for text, code, mermaid, and markdown + return "side-by-side"; +}; + +// Helper function to get default modes based on content type +const getDefaultModes = (type: ContentType): ContentMode[] => { + switch (type) { + case "html": + return ["rendered", "raw"]; + case "mermaid": + return ["rendered", "raw"]; + case "markdown": + return ["rendered", "raw"]; + case "image": + return ["rendered"]; + case "text": + return ["raw"]; + case "code": + return ["raw"]; + default: + return ["rendered"]; + } +}; + +// Helper function to determine initial mode for an item +const getInitialMode = (item: ComparisonItem): ContentMode => { + // Use explicit defaultMode if provided + if (item.defaultMode) { + return item.defaultMode; + } + + // Otherwise use the first available mode + const modes = item.availableModes || getDefaultModes(item.type); + return modes[0]; +}; + +// Slider Comparison Component (original behavior) +const SliderComparison: React.FC> = ({ + left, + right, + defaultPosition = 50, + height = "600px", + syncModes = true, +}) => { + const { t } = useTranslation(); + const [sliderPosition, setSliderPosition] = useState(defaultPosition); + const [isDragging, setIsDragging] = useState(false); + + // Mode states - use defaultMode if provided, otherwise use first available mode + const [leftMode, setLeftMode] = useState(() => getInitialMode(left)); + const [rightMode, setRightMode] = useState(() => getInitialMode(right)); + + const containerRef = useRef(null); + + const leftModes = left.availableModes || getDefaultModes(left.type); + const rightModes = right.availableModes || getDefaultModes(right.type); + + const updateSliderPosition = useCallback((clientX: number) => { + if (!containerRef.current) return; + + const rect = containerRef.current.getBoundingClientRect(); + const x = clientX - rect.left; + const percentage = (x / rect.width) * 100; + + setSliderPosition(Math.min(Math.max(percentage, 0), 100)); + }, []); + + const handleMouseDown = () => { + setIsDragging(true); + }; + + const handleMouseUp = useCallback(() => { + setIsDragging(false); + }, []); + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + updateSliderPosition(e.clientX); + }, + [updateSliderPosition] + ); + + const handleTouchStart = () => { + setIsDragging(true); + }; + + const handleTouchEnd = () => { + setIsDragging(false); + }; + + const handleTouchMove = (e: React.TouchEvent) => { + const touch = e.touches[0]; + updateSliderPosition(touch.clientX); + }; + + // Mode change handlers for SwitchComponent + const handleLeftModeChange = (checked: boolean) => { + const newMode: ContentMode = checked ? "rendered" : "raw"; + setLeftMode(newMode); + if (syncModes) { + setRightMode(newMode); + } + }; + + const handleRightModeChange = (checked: boolean) => { + const newMode: ContentMode = checked ? "rendered" : "raw"; + setRightMode(newMode); + if (syncModes) { + setLeftMode(newMode); + } + }; + + // Attach global mouse event listeners when dragging + useEffect(() => { + if (isDragging) { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + document.body.style.userSelect = "none"; + document.body.style.cursor = "col-resize"; + } else { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + + document.body.style.userSelect = ""; + document.body.style.cursor = ""; + } + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.body.style.userSelect = ""; + document.body.style.cursor = ""; + }; + }, [isDragging, handleMouseMove, handleMouseUp]); + + return ( +
+ {/* Background Layer (Right) */} +
+
+ {rightModes.length > 1 && ( + + )} + {right.label && ( +
+ {right.label} +
+ )} +
+ + +
+ + {/* Foreground Layer (Left) - Clipped */} +
+
+ {left.label && ( +
+ {left.label} +
+ )} + {leftModes.length > 1 && ( + + )} +
+ + +
+ + {/* Slider Divider */} +
+
+ + + + +
+
+
+ ); +}; + +// Side-by-Side Comparison Component (new) +const SideBySideComparison: React.FC> = ({ + left, + right, + height = "600px", + syncModes = true, +}) => { + const { t } = useTranslation(); + // Mode states - use defaultMode if provided, otherwise use first available mode + const [leftMode, setLeftMode] = useState(() => getInitialMode(left)); + const [rightMode, setRightMode] = useState(() => getInitialMode(right)); + + const leftModes = left.availableModes || getDefaultModes(left.type); + const rightModes = right.availableModes || getDefaultModes(right.type); + + // Mode change handlers for SwitchComponent + const handleLeftModeChange = (checked: boolean) => { + const newMode: ContentMode = checked ? "rendered" : "raw"; + setLeftMode(newMode); + if (syncModes) { + setRightMode(newMode); + } + }; + + const handleRightModeChange = (checked: boolean) => { + const newMode: ContentMode = checked ? "rendered" : "raw"; + setRightMode(newMode); + if (syncModes) { + setLeftMode(newMode); + } + }; + + return ( +
+
+ {/* Left Panel */} +
+
+ {left.label && ( +
+ {left.label} +
+ )} + {leftModes.length > 1 && ( + + )} +
+ +
+ + {/* Right Panel */} +
+
+ {rightModes.length > 1 && ( + + )} + {right.label && ( +
+ {right.label} +
+ )} +
+ +
+
+
+ ); +}; + +// Main Comparison Component +export const Comparison: React.FC = (props) => { + const { left, right, layout } = props; + + // Determine layout automatically if not specified + const effectiveLayout = layout || determineLayout(left, right); + + // Render the appropriate layout + if (effectiveLayout === "slider") { + return ; + } else { + return ; + } +}; + diff --git a/src/components/composites/Comparison/types.ts b/src/components/composites/Comparison/types.ts new file mode 100644 index 00000000..4f7871ca --- /dev/null +++ b/src/components/composites/Comparison/types.ts @@ -0,0 +1,29 @@ +import React from "react"; + +// Types for content modes +export type ContentMode = "rendered" | "raw"; + +export type ContentType = "html" | "text" | "code" | "mermaid" | "markdown" | "image" | "custom"; + +// Layout types for comparison +export type ComparisonLayout = "slider" | "side-by-side"; + +export interface ComparisonItem { + content: string | unknown; + type: ContentType; + label?: string; + language?: string; // For code highlighting + availableModes?: ContentMode[]; // Modes available for this item + defaultMode?: ContentMode; // Initial mode for this item + customRender?: (content: unknown, mode: ContentMode) => React.ReactNode; +} + +export interface ComparisonProps { + left: ComparisonItem; + right: ComparisonItem; + defaultPosition?: number; // 0-100, default 50 (only for slider layout) + height?: string; // CSS height value, default "600px" + syncModes?: boolean; // If true, both panels change mode together (default: true) + layout?: ComparisonLayout; // Layout type, auto-determined if not specified +} + diff --git a/src/components/composites/ComparisonRenderer/ComparisonRenderer.tsx b/src/components/composites/ComparisonRenderer/ComparisonRenderer.tsx new file mode 100644 index 00000000..75bf19a9 --- /dev/null +++ b/src/components/composites/ComparisonRenderer/ComparisonRenderer.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import { Comparison } from "../Comparison/Comparison"; +import type { ComparisonItem, ContentType, ComparisonLayout, ContentMode } from "../Comparison/types"; +import { TMetadata } from "../Markdowner/types"; + +interface ComparisonRendererProps { + code: string; + metadata: TMetadata; + wholeMD: string; + node: any; + allowCreate: boolean; +} + +export const ComparisonRenderer: React.FC = ({ + code, + metadata, +}) => { + // Split content by separator + const parts = code.split("---SEPARATOR---"); + + if (parts.length !== 2) { + return ( +
+ Error: El componente Comparison requiere exactamente 2 bloques de contenido separados por "---SEPARATOR---" +
+ ); + } + + const [leftContent, rightContent] = parts.map(p => p.trim()); + + // Extract and validate metadata + const type = (metadata.type as string || "code") as ContentType; + const language = metadata.language as string | undefined; + const leftLabel = metadata.leftLabel as string | undefined; + const rightLabel = metadata.rightLabel as string | undefined; + const layout = metadata.layout as ComparisonLayout | undefined; + const height = (metadata.height as string) || "600px"; + const syncRenderToggle = metadata.syncRenderToggle !== "false" && metadata.syncRenderToggle !== false; // default true + const leftInitialMode = metadata.leftInitialMode as ContentMode | undefined; + const rightInitialMode = metadata.rightInitialMode as ContentMode | undefined; + + // Validate type + const validTypes: ContentType[] = ["html", "text", "code", "mermaid", "markdown", "image", "custom"]; + if (!validTypes.includes(type)) { + return ( +
+ Error: El tipo "{type}" no es válido. Usa: html, text, code, mermaid, markdown, image. +
+ ); + } + + // Validate code type requires language + if (type === "code" && !language) { + return ( +
+ Error: El tipo "code" requiere el atributo "language" (ej: language="python") +
+ ); + } + + // Build comparison items + const left: ComparisonItem = { + content: leftContent, + type: type, + label: leftLabel, + language: language, + defaultMode: leftInitialMode, + }; + + const right: ComparisonItem = { + content: rightContent, + type: type, + label: rightLabel, + language: language, + defaultMode: rightInitialMode, + }; + + return ( +
+ +
+ ); +}; + diff --git a/src/components/composites/Markdowner/Markdowner.tsx b/src/components/composites/Markdowner/Markdowner.tsx index ba48e774..96c1e9a8 100644 --- a/src/components/composites/Markdowner/Markdowner.tsx +++ b/src/components/composites/Markdowner/Markdowner.tsx @@ -74,6 +74,7 @@ import MonacoEditor from "@monaco-editor/react"; import { configureMonacoTypeScript } from "../../../utils/monacoTsConfig"; import { Toolbar } from "../Editor/Editor"; import { eventBus } from "@/managers/eventBus"; +import { ComparisonRenderer } from "../ComparisonRenderer/ComparisonRenderer"; import TelemetryManager from "../../../managers/telemetry"; import { Notifier } from "../../../managers/Notifier"; @@ -782,6 +783,31 @@ const CustomCodeBlock = ({ return ; } + if (language === "comparison") { + if (isCreator && mode === "creator" && allowCreate) { + return ( + + + + ); + } + return ( + + ); + } + if (language === "changesDiff") { return ; } diff --git a/src/locales/en.json b/src/locales/en.json index cdfe6419..25e78c94 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -136,6 +136,7 @@ "we-got-you-covered": "We got you covered", "please-select-option": "Please select an option", "code": "Code", + "preview": "Preview", "output": "Output", "compile-first": "You must compile or test your code to see the output", "or": "or", diff --git a/src/locales/es.json b/src/locales/es.json index 39f1ce9b..821f96e1 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -143,6 +143,7 @@ "we-got-you-covered": "Te tenemos cubierto", "please-select-option": "Por favor selecciona una opción", "code": "Código", + "preview": "Vista previa", "output": "Salida", "compile-first": "Tienes que compilar o testear tu código para ver la salida", "or": "o",