snow-editor

small markdown and org-mode editor
Log | Files | Refs | README

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 }