all repos — snow-editor @ 24ce9e9ef60be3233adb8eaa03f5a936c1ef4acd

small and cozy markdown, and orgmode editor

src/components/EditorLayout.jsx (view raw)

 1
 2
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
import { useDeferredValue, useEffect, useMemo, useState } from 'react';
import { MODES } from '../lib/editorConstants.js';
import { buildPreviewHtml, ensureMarkedLoaded } from '../lib/previewHtml.js';

function useDividerOrientation() {
  const [orientation, setOrientation] = useState('vertical');

  useEffect(() => {
    const media = window.matchMedia('(max-width: 768px)');
    const update = () => setOrientation(media.matches ? 'horizontal' : 'vertical');
    update();
    media.addEventListener('change', update);
    return () => media.removeEventListener('change', update);
  }, []);

  return orientation;
}

export function countWords(text) {
  const trimmed = text.trim();
  if (!trimmed) return 0;
  return trimmed.split(/\s+/).filter(Boolean).length;
}

export default function EditorLayout({
  mode,
  content,
  onContentChange,
  readOnly = false,
  showEditor = true,
  editorRef,
  previewOnly = false,
}) {
  const [markedReady, setMarkedReady] = useState(false);
  const dividerOrientation = useDividerOrientation();
  const deferredContent = useDeferredValue(content);
  const previewIsStale = content !== deferredContent;

  useEffect(() => {
    if (mode !== MODES.MARKDOWN) return;
    let cancelled = false;
    ensureMarkedLoaded().then(() => {
      if (!cancelled) setMarkedReady(true);
    });
    return () => {
      cancelled = true;
    };
  }, [mode]);

  const html = useMemo(() => {
    return buildPreviewHtml(mode, deferredContent);
  }, [mode, deferredContent, markedReady]);

  const wordCount = useMemo(() => countWords(content), [content]);
  const charCount = content.length;

  const editorAriaLabel =
    mode === MODES.ORG ? 'Org-mode editing area' : 'Markdown editing area';

  return (
    <>
      <main className={`app-layout${previewOnly ? ' app-layout--preview-only' : ''}`}>
        {showEditor && !previewOnly && (
          <>
            <section
              className="panel panel-editor"
              aria-label={mode === MODES.ORG ? 'Org-mode editor' : 'Markdown editor'}
            >
              <div className="panel-label">Write</div>
              <textarea
                ref={editorRef}
                className="editor"
                value={content}
                onChange={onContentChange ? (e) => onContentChange(e.target.value) : undefined}
                readOnly={readOnly}
                spellCheck="true"
                aria-label={editorAriaLabel}
                placeholder="Start writing..."
              />
            </section>

            <div
              className="layout-divider"
              role="separator"
              aria-orientation={dividerOrientation}
            />
          </>
        )}

        <section
          className={`panel panel-preview${previewOnly ? ' panel-preview--full' : ''}`}
          aria-label="Document preview"
        >
          <div className="panel-label">Read</div>
          <div
            className={`preview-paper${previewIsStale ? ' preview-updating' : ''}`}
            dangerouslySetInnerHTML={{ __html: html }}
          />
        </section>
      </main>

      <div className="app-footer-stats app-footer-stats--inline">
        <span>
          {wordCount} {wordCount === 1 ? 'word' : 'words'}
        </span>
        <span className="footer-separator">ยท</span>
        <span>
          {charCount} {charCount === 1 ? 'character' : 'characters'}
        </span>
      </div>
    </>
  );
}