{orgMeta.title}
}{STR.PREVIEW_EMPTY}
) : !rendererReady && !html ? ({STR.PREVIEW_RENDERING}
) : ( )}import { lazy, Suspense, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState, } from 'react'; import { MODES } from '../lib/editorConstants.js'; import { parseMarkdownHeadings } from '../lib/markdownOutline.js'; import { parseOrgDocument } from '../lib/org/parseDocument.js'; import { buildPreviewHtml, ensureMarkedLoaded, ensureOrgLoaded } from '../lib/previewHtml.js'; import { STR } from '../lib/strings.js'; import DocumentOutline from './DocumentOutline.jsx'; const CodeEditor = lazy(() => import('./CodeEditor.jsx')); const SPLIT_STORAGE_KEY = 'snow_editor_split'; const SPLIT_MIN = 0.25; const SPLIT_MAX = 0.75; function clampSplit(value) { if (!Number.isFinite(value)) return 0.5; return Math.min(SPLIT_MAX, Math.max(SPLIT_MIN, value)); } function loadSplit() { try { const saved = window.localStorage.getItem(SPLIT_STORAGE_KEY); if (saved !== null) return clampSplit(Number.parseFloat(saved)); } catch { /* ignore */ } return 0.5; } function persistSplit(value) { try { window.localStorage.setItem(SPLIT_STORAGE_KEY, String(value)); } catch { /* ignore */ } } function useIsMobile() { const [mobile, setMobile] = useState(() => typeof window !== 'undefined' ? window.matchMedia('(max-width: 768px)').matches : false, ); useEffect(() => { const media = window.matchMedia('(max-width: 768px)'); const update = () => setMobile(media.matches); update(); media.addEventListener('change', update); return () => media.removeEventListener('change', update); }, []); return mobile; } function useWideLayout() { const [wide, setWide] = useState(() => typeof window !== 'undefined' ? window.matchMedia('(min-width: 900px)').matches : true, ); useEffect(() => { const media = window.matchMedia('(min-width: 900px)'); const update = () => setWide(media.matches); update(); media.addEventListener('change', update); return () => media.removeEventListener('change', update); }, []); return wide; } export function countWords(text) { const trimmed = text.trim(); if (!trimmed) return 0; return trimmed.split(/\s+/).filter(Boolean).length; } let hljsPromise = null; function ensureHighlightLoaded() { if (!hljsPromise) { // The "common" build ships the ~40 popular languages only. hljsPromise = import('highlight.js/lib/common').then((m) => m.default); } return hljsPromise; } export default function EditorLayout({ mode, content, onContentChange, readOnly = false, showEditor = true, editorRef, previewOnly = false, }) { const [markedReady, setMarkedReady] = useState(false); const [orgReady, setOrgReady] = useState(false); const [activeTab, setActiveTab] = useState('write'); const [split, setSplit] = useState(loadSplit); const [dragging, setDragging] = useState(false); const scrollToLineRef = useRef(null); const previewScrollRef = useRef(null); const previewContentRef = useRef(null); const editorPanelRef = useRef(null); const isMobile = useIsMobile(); const wideLayout = useWideLayout(); const deferredContent = useDeferredValue(content); const previewIsStale = content !== deferredContent; useEffect(() => { if (mode !== MODES.MARKDOWN) return undefined; let cancelled = false; ensureMarkedLoaded().then(() => { if (!cancelled) setMarkedReady(true); }); return () => { cancelled = true; }; }, [mode]); useEffect(() => { if (mode !== MODES.ORG) return undefined; let cancelled = false; ensureOrgLoaded().then(() => { if (!cancelled) setOrgReady(true); }); return () => { cancelled = true; }; }, [mode]); const orgMeta = useMemo(() => { if (mode !== MODES.ORG) return null; return parseOrgDocument(deferredContent); }, [mode, deferredContent]); const headings = useMemo(() => { if (mode === MODES.ORG) return orgMeta?.headings ?? []; return parseMarkdownHeadings(deferredContent); }, [mode, orgMeta, deferredContent]); const html = useMemo(() => { return buildPreviewHtml(mode, deferredContent); }, [mode, deferredContent, markedReady, orgReady]); // Lazy syntax highlighting for fenced/SRC code blocks in the preview. useEffect(() => { const container = previewContentRef.current; if (!container || !html || !html.includes('
{
if (cancelled || !previewContentRef.current) return;
const blocks = previewContentRef.current.querySelectorAll('pre code, pre.org-src');
blocks.forEach((el) => {
if (el.tagName === 'PRE' && el.querySelector('code')) return;
if (el.dataset.highlighted === 'yes') return;
hljs.highlightElement(el);
});
});
return () => {
cancelled = true;
};
}, [html]);
const handleRegisterScroll = useCallback((scrollFn) => {
scrollToLineRef.current = scrollFn;
}, []);
const handleOutlineSelect = useCallback((line) => {
scrollToLineRef.current?.(line);
}, []);
// Editor → preview proportional scroll sync.
const handleEditorScrollRatio = useCallback((ratio) => {
const preview = previewScrollRef.current;
if (!preview) return;
const max = preview.scrollHeight - preview.clientHeight;
if (max > 0) {
preview.scrollTop = ratio * max;
}
}, []);
// Draggable splitter (desktop only).
const handleDividerPointerDown = useCallback(
(event) => {
if (isMobile) return;
event.preventDefault();
setDragging(true);
const editorPanel = editorPanelRef.current;
const previewPanel = previewScrollRef.current?.closest('.panel-preview');
if (!editorPanel || !previewPanel) return;
const onMove = (moveEvent) => {
const left = editorPanel.getBoundingClientRect().left;
const right = previewPanel.getBoundingClientRect().right;
if (right - left <= 0) return;
setSplit(clampSplit((moveEvent.clientX - left) / (right - left)));
};
const onUp = () => {
setDragging(false);
setSplit((value) => {
persistSplit(value);
return value;
});
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
},
[isMobile],
);
const handleDividerReset = useCallback(() => {
setSplit(0.5);
persistSplit(0.5);
}, []);
const wordCount = useMemo(() => countWords(content), [content]);
const charCount = content.length;
const editorAriaLabel =
mode === MODES.ORG ? 'Org-mode editing area' : 'Markdown editing area';
const splitLayout = showEditor && !previewOnly;
const useTabs = isMobile && splitLayout;
const showWrite = splitLayout && (!useTabs || activeTab === 'write');
const showRead = previewOnly || !useTabs || activeTab === 'read';
const showOutline =
splitLayout && !useTabs && wideLayout && headings.length > 0;
const rendererReady = mode === MODES.ORG ? orgReady : markedReady;
const contentEmpty = !deferredContent || !deferredContent.trim();
const gridStyle =
splitLayout && !useTabs
? {
gridTemplateColumns: showOutline
? `10.5rem ${split}fr 2rem ${1 - split}fr`
: `${split}fr 2rem ${1 - split}fr`,
}
: undefined;
return (
<>
{useTabs && (
)}
{showOutline && (
)}
{showWrite && (
<>
{STR.TAB_WRITE}
Loading editor…}
>
{!useTabs && (
)}
>
)}
{showRead && (
{STR.TAB_READ}
{orgMeta?.title && {orgMeta.title}
}
{contentEmpty ? (
{STR.PREVIEW_EMPTY}
) : !rendererReady && !html ? (
{STR.PREVIEW_RENDERING}
) : (
)}
)}
{wordCount} {wordCount === 1 ? 'word' : 'words'}
·
{charCount} {charCount === 1 ? 'character' : 'characters'}
>
);
}