EditorLayout.jsx (5837B)
1 import { lazy, Suspense, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'; 2 import { MODES } from '../lib/editorConstants.js'; 3 import { parseOrgDocument } from '../lib/org/parseDocument.js'; 4 import { buildPreviewHtml, ensureMarkedLoaded, ensureOrgLoaded } from '../lib/previewHtml.js'; 5 import OrgOutline from './OrgOutline.jsx'; 6 7 const OrgEditor = lazy(() => import('./OrgEditor.jsx')); 8 9 function useDividerOrientation() { 10 const [orientation, setOrientation] = useState('vertical'); 11 12 useEffect(() => { 13 const media = window.matchMedia('(max-width: 768px)'); 14 const update = () => setOrientation(media.matches ? 'horizontal' : 'vertical'); 15 update(); 16 media.addEventListener('change', update); 17 return () => media.removeEventListener('change', update); 18 }, []); 19 20 return orientation; 21 } 22 23 function useWideLayout() { 24 const [wide, setWide] = useState(() => 25 typeof window !== 'undefined' ? window.matchMedia('(min-width: 900px)').matches : true, 26 ); 27 28 useEffect(() => { 29 const media = window.matchMedia('(min-width: 900px)'); 30 const update = () => setWide(media.matches); 31 update(); 32 media.addEventListener('change', update); 33 return () => media.removeEventListener('change', update); 34 }, []); 35 36 return wide; 37 } 38 39 export function countWords(text) { 40 const trimmed = text.trim(); 41 if (!trimmed) return 0; 42 return trimmed.split(/\s+/).filter(Boolean).length; 43 } 44 45 export default function EditorLayout({ 46 mode, 47 content, 48 onContentChange, 49 readOnly = false, 50 showEditor = true, 51 editorRef, 52 previewOnly = false, 53 }) { 54 const [markedReady, setMarkedReady] = useState(false); 55 const [orgReady, setOrgReady] = useState(false); 56 const scrollToLineRef = useRef(null); 57 const dividerOrientation = useDividerOrientation(); 58 const wideLayout = useWideLayout(); 59 const deferredContent = useDeferredValue(content); 60 const previewIsStale = content !== deferredContent; 61 62 useEffect(() => { 63 if (mode !== MODES.MARKDOWN) return undefined; 64 let cancelled = false; 65 ensureMarkedLoaded().then(() => { 66 if (!cancelled) setMarkedReady(true); 67 }); 68 return () => { 69 cancelled = true; 70 }; 71 }, [mode]); 72 73 useEffect(() => { 74 if (mode !== MODES.ORG) return undefined; 75 let cancelled = false; 76 ensureOrgLoaded().then(() => { 77 if (!cancelled) setOrgReady(true); 78 }); 79 return () => { 80 cancelled = true; 81 }; 82 }, [mode]); 83 84 const orgMeta = useMemo(() => { 85 if (mode !== MODES.ORG) return null; 86 return parseOrgDocument(deferredContent); 87 }, [mode, deferredContent]); 88 89 const html = useMemo(() => { 90 return buildPreviewHtml(mode, deferredContent); 91 }, [mode, deferredContent, markedReady, orgReady]); 92 93 const handleRegisterScroll = useCallback((scrollFn) => { 94 scrollToLineRef.current = scrollFn; 95 }, []); 96 97 const handleOutlineSelect = useCallback((line) => { 98 scrollToLineRef.current?.(line); 99 }, []); 100 101 const wordCount = useMemo(() => countWords(content), [content]); 102 const charCount = content.length; 103 104 const editorAriaLabel = 105 mode === MODES.ORG ? 'Org-mode editing area' : 'Markdown editing area'; 106 107 const showOutline = 108 mode === MODES.ORG && showEditor && !previewOnly && wideLayout && orgMeta?.headings?.length > 0; 109 110 return ( 111 <> 112 <main 113 className={`app-layout${previewOnly ? ' app-layout--preview-only' : ''}${showOutline ? ' app-layout--with-outline' : ''}`} 114 > 115 {showOutline && ( 116 <OrgOutline content={content} onSelectHeading={handleOutlineSelect} /> 117 )} 118 119 {showEditor && !previewOnly && ( 120 <> 121 <section 122 className="panel panel-editor" 123 aria-label={mode === MODES.ORG ? 'Org-mode editor' : 'Markdown editor'} 124 > 125 <div className="panel-label">Write</div> 126 {mode === MODES.ORG ? ( 127 <Suspense fallback={<div className="editor editor--loading">Loading Org editor…</div>}> 128 <OrgEditor 129 value={content} 130 onChange={onContentChange} 131 readOnly={readOnly} 132 editorRef={editorRef} 133 ariaLabel={editorAriaLabel} 134 onRegisterScroll={handleRegisterScroll} 135 /> 136 </Suspense> 137 ) : ( 138 <textarea 139 ref={editorRef} 140 className="editor" 141 value={content} 142 onChange={onContentChange ? (e) => onContentChange(e.target.value) : undefined} 143 readOnly={readOnly} 144 spellCheck="true" 145 aria-label={editorAriaLabel} 146 placeholder="Start writing..." 147 /> 148 )} 149 </section> 150 151 <div 152 className="layout-divider" 153 role="separator" 154 aria-orientation={dividerOrientation} 155 /> 156 </> 157 )} 158 159 <section 160 className={`panel panel-preview${previewOnly ? ' panel-preview--full' : ''}`} 161 aria-label="Document preview" 162 > 163 <div className="panel-label">Read</div> 164 {orgMeta?.title && ( 165 <p className="org-doc-title">{orgMeta.title}</p> 166 )} 167 <div 168 className={`preview-paper${previewIsStale ? ' preview-updating' : ''}`} 169 dangerouslySetInnerHTML={{ __html: html }} 170 /> 171 </section> 172 </main> 173 174 <div className="app-footer-stats app-footer-stats--inline"> 175 <span> 176 {wordCount} {wordCount === 1 ? 'word' : 'words'} 177 </span> 178 <span className="footer-separator">·</span> 179 <span> 180 {charCount} {charCount === 1 ? 'character' : 'characters'} 181 </span> 182 </div> 183 </> 184 ); 185 }