diff --git a/resources/js/components/theme/editor-block.tsx b/resources/js/components/theme/editor-block.tsx new file mode 100644 index 0000000..c7f79e6 --- /dev/null +++ b/resources/js/components/theme/editor-block.tsx @@ -0,0 +1,265 @@ +/** + * EditorBlock Component + * + * A Monaco-based code editor component that mirrors the visual style of CodeBlock. + * It supports syntax highlighting, theme integration (light/dark), full-screen mode, + * and copy-to-clipboard functionality. + * + * Usage Example: + * ```tsx + * import EditorBlock from '@/components/theme/editor-block'; + * + * function MyComponent() { + * const [code, setCode] = useState('console.log("Hello World");'); + * + * return ( + * + * ); + * } + * ``` + * + * Required Packages: + * - @monaco-editor/react + * - lucide-react + * - clsx + * - tailwind-merge + * + * Note: This component is client-only and will render a placeholder during SSR. + */ + +'use client'; + +import Editor, { type EditorProps } from '@monaco-editor/react'; +import { Check, Copy, Maximize2, Minimize2 } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/button'; +import { useAppearance } from '@/hooks/use-appearance'; +import { useClipboard } from '@/hooks/use-clipboard'; +import { cn } from '@/lib/utils'; + +interface EditorBlockProps { + /** Current code value */ + value: string; + /** Callback when value changes */ + onChange?: (value: string) => void; + /** Language for syntax highlighting (default: "javascript") */ + language?: string; + /** Whether the editor is read-only (default: false) */ + readOnly?: boolean; + /** Whether to hide line numbers (default: false) */ + hideLineNumbers?: boolean; + /** Additional CSS classes for the container */ + className?: string; + /** Visual variant: 'default' has a header, 'minimal' has floating controls */ + variant?: 'default' | 'minimal'; + /** Whether to show the copy button (default: true) */ + showCopyButton?: boolean; + /** Whether to show the full-screen toggle (default: true) */ + showFullScreenToggle?: boolean; + /** Height of the editor (e.g., "300px", "50vh") (default: "300px") */ + height?: string | number; + /** Override the automatically detected theme ('light' or 'dark') */ + themeOverride?: 'light' | 'dark'; + /** Additional Monaco editor options */ + options?: EditorProps['options']; + /** Whether to automatically resize the editor when the container changes */ + autoResize?: boolean; +} + +export default function EditorBlock({ + value, + onChange, + language = 'javascript', + readOnly = false, + hideLineNumbers = false, + className, + variant = 'default', + showCopyButton = true, + showFullScreenToggle = true, + height = '300px', + themeOverride, + options, + autoResize = true, +}: EditorBlockProps) { + const [mounted, setMounted] = useState(false); + const [copied, setCopied] = useState(false); + const [isFullScreen, setIsFullScreen] = useState(false); + const containerRef = useRef(null); + const [, copy] = useClipboard(); + const { resolvedAppearance } = useAppearance(); + + const currentTheme = themeOverride || resolvedAppearance; + const monacoTheme = currentTheme === 'dark' ? 'vs-dark' : 'light'; + + const languageMap: Record = { + js: 'javascript', + ts: 'typescript', + css: 'css', + php: 'php', + markup: 'markup', + sh: 'bash', + shell: 'bash', + html: 'markup', + }; + + const normalizedLanguage = + languageMap[language.toLowerCase()] || language.toLowerCase(); + + useEffect(() => { + setMounted(true); + }, []); + + const handleCopy = useCallback(async () => { + const success = await copy(value); + + if (success) { + setCopied(true); + toast.success('Copied to clipboard'); + setTimeout(() => setCopied(false), 2000); + } else { + toast.error('Failed to copy to clipboard'); + } + }, [value, copy]); + + const toggleFullScreen = useCallback(() => { + if (!containerRef.current) return; + + if (!document.fullscreenElement) { + containerRef.current.requestFullscreen().catch((err) => { + toast.error(`Error attempting to enable full-screen mode: ${err.message}`); + }); + } else { + document.exitFullscreen(); + } + }, []); + + useEffect(() => { + const handleFullScreenChange = () => { + setIsFullScreen(!!document.fullscreenElement); + }; + + document.addEventListener('fullscreenchange', handleFullScreenChange); + return () => document.removeEventListener('fullscreenchange', handleFullScreenChange); + }, []); + + const editorOptions: EditorProps['options'] = { + minimap: { enabled: false }, + wordWrap: 'on', + fontSize: 14, + lineNumbers: hideLineNumbers ? 'off' : 'on', + readOnly, + automaticLayout: autoResize, + scrollBeyondLastLine: false, + padding: { top: 12, bottom: 12 }, + ...options, + }; + + if (!mounted) { + return ( +
+ ); + } + + const Controls = () => ( +
+ {showCopyButton && ( +
+ + Copied + + +
+ )} + {showFullScreenToggle && ( + + )} +
+ ); + + return ( +
+ {variant === 'default' && ( +
+ + {normalizedLanguage} + + +
+ )} + + {variant === 'minimal' && ( +
+ +
+ )} + +
+ onChange?.(val || '')} + options={editorOptions} + loading={ +
+ Loading editor... +
+ } + /> +
+
+ ); +}