snow-editor

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

OrgEditor.jsx (3046B)


      1 import { Compartment, EditorState } from '@codemirror/state';
      2 import {
      3   drawSelection,
      4   EditorView,
      5   highlightActiveLine,
      6   keymap,
      7   lineNumbers,
      8 } from '@codemirror/view';
      9 import { defaultKeymap, indentWithTab } from '@codemirror/commands';
     10 import { org } from '@orgajs/cm-lang';
     11 import { useEffect, useRef } from 'react';
     12 import { checklistPlugin } from '../lib/org/checklistPlugin.js';
     13 import { orgKeymap } from '../lib/org/keymap.js';
     14 import { orgTheme } from '../lib/org/orgTheme.js';
     15 
     16 export default function OrgEditor({
     17   value,
     18   onChange,
     19   readOnly = false,
     20   editorRef,
     21   ariaLabel,
     22   onRegisterScroll,
     23 }) {
     24   const containerRef = useRef(null);
     25   const viewRef = useRef(null);
     26   const readOnlyRef = useRef(readOnly);
     27   const onChangeRef = useRef(onChange);
     28   const editableCompartment = useRef(new Compartment());
     29 
     30   readOnlyRef.current = readOnly;
     31   onChangeRef.current = onChange;
     32 
     33   useEffect(() => {
     34     if (!containerRef.current) return undefined;
     35 
     36     const updateListener = EditorView.updateListener.of((update) => {
     37       if (update.docChanged && onChangeRef.current) {
     38         onChangeRef.current(update.state.doc.toString());
     39       }
     40     });
     41 
     42     const state = EditorState.create({
     43       doc: value,
     44       extensions: [
     45         org(),
     46         orgTheme,
     47         lineNumbers(),
     48         highlightActiveLine(),
     49         drawSelection(),
     50         editableCompartment.current.of(EditorView.editable.of(!readOnlyRef.current)),
     51         EditorState.readOnly.of(readOnlyRef.current),
     52         EditorView.contentAttributes.of({ 'aria-label': ariaLabel }),
     53         updateListener,
     54         checklistPlugin(() => readOnlyRef.current),
     55         orgKeymap,
     56         keymap.of([...defaultKeymap, indentWithTab]),
     57       ],
     58     });
     59 
     60     const view = new EditorView({ state, parent: containerRef.current });
     61     viewRef.current = view;
     62 
     63     if (editorRef) {
     64       editorRef.current = {
     65         focus: () => view.focus(),
     66         getView: () => view,
     67       };
     68     }
     69 
     70     onRegisterScroll?.((lineNumber) => {
     71       const line = view.state.doc.line(
     72         Math.min(Math.max(1, lineNumber), view.state.doc.lines),
     73       );
     74       view.dispatch({
     75         effects: EditorView.scrollIntoView(line.from, { y: 'center' }),
     76         selection: { anchor: line.from },
     77       });
     78       view.focus();
     79     });
     80 
     81     return () => {
     82       view.destroy();
     83       viewRef.current = null;
     84       if (editorRef) editorRef.current = null;
     85     };
     86   }, [ariaLabel, editorRef, onRegisterScroll]);
     87 
     88   useEffect(() => {
     89     const view = viewRef.current;
     90     if (!view) return;
     91 
     92     const current = view.state.doc.toString();
     93     if (current !== value) {
     94       view.dispatch({
     95         changes: { from: 0, to: current.length, insert: value },
     96       });
     97     }
     98   }, [value]);
     99 
    100   useEffect(() => {
    101     const view = viewRef.current;
    102     if (!view) return;
    103 
    104     view.dispatch({
    105       effects: editableCompartment.current.reconfigure(
    106         EditorView.editable.of(!readOnly),
    107       ),
    108     });
    109   }, [readOnly]);
    110 
    111   return <div ref={containerRef} className="org-editor cm-host" />;
    112 }