intraxia/wp-gistpen

View on GitHub
client/components/Editor/Code.tsx

Summary

Maintainability
C
1 day
Test Coverage
import Kefir, { Observable } from 'kefir';
import { raf$, toJunction, withRef$, Refback } from 'brookjs';
import React, { memo, forwardRef } from 'react';
import Prism from 'prismjs';
import {
  editorCursorMoveWithKey,
  editorIndentWithKey,
  editorMakeComment,
  editorMakeNewlineWithKey,
  editorRedo,
  editorUndo,
  editorValueChangeWithKey,
} from '../../actions';
import { selectSelectionStart, selectSelectionEnd } from '../../selectors';
import { prismSlug, setTheme, togglePlugin } from '../../prism';
import { RootAction } from '../../RootAction';
import { Toggle } from '../../api';
import { Cursor } from '../../editor/types';
import { isSpecialEvent, languageIsEqual, editorOptionsIsEqual } from './util';
import findOffset from './findOffset';

type Props = {
  code: string;
  cursor: Cursor;
  language: string;
  theme: string;
  invisibles: Toggle;
  onBlur: (e: React.FocusEvent<HTMLElement>) => void;
  onClick: (e: React.MouseEvent<HTMLElement>) => void;
  onFocus: (e: React.FocusEvent<HTMLElement>) => void;
  onInput: (e: React.ChangeEvent<HTMLElement>) => void;
  onKeyUp: (e: React.KeyboardEvent<HTMLElement>) => void;
  onKeyDown: (e: React.KeyboardEvent<HTMLElement>) => void;
};

const elementToCursorMoveAction = (e: Element) =>
  editorCursorMoveWithKey(
    [selectSelectionStart(e), selectSelectionEnd(e)],
    null,
  );

// Use this to fill in type at call site.
const mapToTargetCursorAction = <
  E extends React.SyntheticEvent<HTMLElement>
>() => (evt$: Observable<E, never>) =>
  evt$.map(e => elementToCursorMoveAction(e.target as Element));

const mapKeydownToAction = (
  evt: React.KeyboardEvent<HTMLElement>,
): RootAction => {
  const { shiftKey: inverse } = evt;
  let { textContent: code } = evt.target as HTMLElement;
  code = code || '';
  const cursor: Cursor = [
    selectSelectionStart(evt.target as Element),
    selectSelectionEnd(evt.target as Element),
  ];

  evt.preventDefault();

  switch (evt.keyCode) {
    case 9: // Tab
      return editorIndentWithKey({ code, cursor, inverse }, null);
    case 13:
      return editorMakeNewlineWithKey({ code, cursor }, null);
    case 90:
      return inverse ? editorRedo() : editorUndo();
    case 191:
      return editorMakeComment({ code, cursor });
  }

  throw new Error('Keydown is missing matching actions case');
};

const setSelectionRange = (node: Element, ss: number, se: number) =>
  Kefir.stream<never, never>(emitter => {
    if (ss === selectSelectionStart(node) && se === selectSelectionEnd(node)) {
      return emitter.end();
    }

    const range = document.createRange();
    const offsetStart = findOffset(node, ss);
    let offsetEnd = offsetStart;

    if (se && se !== ss) {
      offsetEnd = findOffset(node, se);
    }

    if (offsetStart.error == null && offsetEnd.error == null) {
      range.setStart(offsetStart.element, offsetStart.offset);

      range.setEnd(offsetEnd.element, offsetEnd.offset);

      const selection = window.getSelection();
      if (selection != null) {
        selection.removeAllRanges();
        selection.addRange(range);
      }
    }

    emitter.end();
  }).setName('setSelectionRange$');

const highlightElement = (el: Element) =>
  Kefir.stream<never, never>(emitter => {
    Prism.highlightElement(el, false);
    emitter.end();
  }).setName('highlightElement$');

let init = false;

const createPrismUpdateStream = (props: Props) =>
  // Since we're igonring the values anyway, I don't mind casting to `any`.
  Kefir.fromPromise<any, never>(
    Promise.all([
      setTheme(props.theme),
      togglePlugin('line-numbers', true).then(() => {
        // We only need to register this callback once, but only after the
        // plugin has been loaded once.
        if (!init) {
          init = true;
          Prism.hooks.add('line-numbers', env => {
            const code = env.element;
            const pre = code?.parentNode as HTMLPreElement | null;

            if (pre == null || code == null) {
              return;
            }

            const incoming = code.querySelector('.line-numbers-rows');
            const outgoings = pre.querySelectorAll('.line-numbers-rows');

            for (let i = 0; i < outgoings.length; i++) {
              const outgoing = outgoings[i];

              if (outgoing !== incoming) {
                outgoing.remove();
              }
            }

            incoming != null && pre.appendChild(incoming);
          });
        }
      }),
      togglePlugin('show-invisibles', props.invisibles === 'on'),
    ]),
  )
    .ignoreValues()
    .setName('prismUpdate$');

const createDOMUpdateStream = (el: Element, props: Props) =>
  raf$.take(1).flatMap(() =>
    Kefir.concat<never, never>([
      Kefir.stream(emitter => {
        el.textContent = props.code;
        emitter.end();
      }),
      highlightElement(el),
      props.cursor
        ? setSelectionRange(el, props.cursor[0], props.cursor[1])
        : Kefir.never(),
    ]),
  );

const Code: React.RefForwardingComponent<HTMLElement, Props> = (
  { language, onBlur, onClick, onFocus, onInput, onKeyUp, onKeyDown },
  ref,
) => (
  <code
    onBlur={onBlur}
    onClick={onClick}
    onFocus={onFocus}
    onInput={onInput}
    onKeyUp={onKeyUp}
    onKeyDown={onKeyDown}
    className={`language-${prismSlug(language)}`}
    ref={ref}
    contentEditable
    spellCheck={false}
  />
);

const refback: Refback<Props, HTMLElement, RootAction> = (ref$, props$) =>
  ref$.flatMap(el => {
    const keyUp$ = Kefir.fromEvents<KeyboardEvent, never>(el, 'keyup').setName(
      'keyUp$',
    );
    const keyDown$ = Kefir.fromEvents<KeyboardEvent, never>(
      el,
      'keydown',
    ).setName('keyDown$');

    /**
     * Create initial render stream.
     *
     * This handles the render on pages load, making sure the editor
     * gets highlighted immediately. `props$` is a Kefir.Property,
     * so we get a value immediately.
     */
    const initial$ = props$
      .take(1)
      .flatMapLatest(props => createDOMUpdateStream(el, props))
      .setName('initial$');

    /**
     * Create options update & render stream.
     *
     * This stream covers options changes & rerenders the editor.
     * There's no need to debounce or cancel because the user will
     * either be interacting with the options panel, so there's no
     * chance of messing up typing.
     */
    const options$ = props$
      .skipDuplicates(editorOptionsIsEqual)
      .flatMapLatest(props =>
        Kefir.concat([
          createPrismUpdateStream(props),
          createDOMUpdateStream(el, props),
        ]),
      )
      .setName('options$');

    /**
     * Create typing render stream.
     *
     * This stream ensures the rerenders don't take place while
     * the user is typing. We use a debounced keyup to ensure
     * the props
     */
    const typing$ = props$
      .sampledBy(keyUp$.debounce(10))
      .skipDuplicates((prev, next) => prev.code === next.code)
      .flatMapLatest(props =>
        createDOMUpdateStream(el, props).takeUntilBy(keyDown$),
      )
      .setName('typing$');

    /**
     * Create special keys renders stream.
     *
     * There are a few keys that run through the reducer logic. These
     * need to update the editor immediately, interrupting the user
     * typing to update the code in the editor and the cursor location.
     * The render is thus done synchronously.
     */
    const special$ = props$
      .sampledBy(keyDown$.filter(isSpecialEvent).delay(0))
      .skipDuplicates((prev, next) => prev.code === next.code)
      .flatMapLatest(props =>
        raf$.take(1).flatMap(() => createDOMUpdateStream(el, props)),
      )
      .setName('special$');

    const language$ = props$
      .skipDuplicates(languageIsEqual)
      .flatMapLatest(props => createDOMUpdateStream(el, props))
      .setName('language$');

    return Kefir.merge<RootAction, never>([
      initial$,
      options$,
      typing$,
      special$,
      language$,
    ]);
  });

const events = {
  onBlur: (evt$: Observable<React.FocusEvent<HTMLElement>, never>) =>
    evt$.map(() => editorCursorMoveWithKey(null, null)),
  onClick: mapToTargetCursorAction<React.MouseEvent<HTMLElement>>(),
  onFocus: mapToTargetCursorAction<React.FocusEvent<HTMLElement>>(),
  onInput: (evt$: Observable<React.ChangeEvent<HTMLElement>, never>) =>
    evt$.map(evt =>
      editorValueChangeWithKey(
        {
          code: (evt.target as HTMLElement).textContent || '',
          cursor: [
            selectSelectionStart(evt.target as Element),
            selectSelectionEnd(evt.target as Element),
          ],
        },
        null,
      ),
    ),
  onKeyUp: (evt$: Observable<React.KeyboardEvent<HTMLPreElement>, never>) =>
    evt$.filter(e => !isSpecialEvent(e)).thru(mapToTargetCursorAction()),
  onKeyDown: (evt$: Observable<React.KeyboardEvent<HTMLPreElement>, never>) =>
    evt$.filter(e => isSpecialEvent(e)).map(mapKeydownToAction),
};

export default toJunction(events)(
  withRef$(refback)(
    memo(
      forwardRef(Code),
      (prev: Props, next: Props) => prev.language === next.language,
    ),
  ),
);