intraxia/wp-gistpen

View on GitHub
client/reducers/editor.ts

Summary

Maintainability
F
6 days
Test Coverage
import { getType } from 'typesafe-actions';
import { EddyReducer } from 'brookjs';
import {
  editorThemeChange,
  editorTabsToggle,
  editorInvisiblesToggle,
  editorDescriptionChange,
  editorStatusChange,
  editorSyncToggle,
  editorAddClick,
  editorDeleteClickWithKey,
  editorCursorMoveWithKey,
  editorFilenameChangeWithKey,
  editorLanguageChangeWithKey,
  editorIndentWithKey,
  editorMakeNewlineWithKey,
  EditorValue,
  EditorIndentValue,
  editorValueChangeWithKey,
  repoSaveSucceeded,
  init,
  ajaxFailed,
} from '../actions';
import { RootAction } from '../RootAction';
import { AjaxError, Toggle } from '../api';
import { Cursor } from '../editor/types';
import { editorWidthChange } from '../editor/actions';

export type EditorSnapshot = {
  code: string;
  cursor: Cursor;
};

export type EditorHistory = {
  undo: Array<EditorSnapshot>;
  redo: Array<EditorSnapshot>;
};

export type EditorInstance = {
  key: string;
  filename: string;
  code: string;
  language: string;
  cursor: Cursor;
  history: EditorHistory;
};

export type EditorState = {
  description: string;
  status: string;
  password: string;
  gist_id: string;
  sync: Toggle;
  instances: Array<EditorInstance>;
  width: string;
  theme: string;
  invisibles: Toggle;
  tabs: Toggle;
  errors: AjaxError[];
};

const defaultInstance: EditorInstance = {
  key: 'new0',
  filename: '',
  code: '\n',
  language: 'plaintext',
  cursor: null,
  history: {
    undo: [],
    redo: [],
  },
};

const defaultState: EditorState = {
  theme: 'default',
  tabs: 'off',
  width: '4',
  invisibles: 'off',
  description: '',
  status: 'draft',
  password: '',
  gist_id: '',
  sync: 'off',
  instances: [defaultInstance],
  errors: [],
};

export const editorReducer: EddyReducer<EditorState, RootAction> = (
  state = defaultState,
  action: RootAction,
) => {
  switch (action.type) {
    case getType(editorThemeChange):
      return {
        ...state,
        theme: action.payload.value,
      };
    case getType(editorTabsToggle):
      return {
        ...state,
        tabs: action.payload.value,
      };
    case getType(editorWidthChange):
      return {
        ...state,
        width: `${action.payload.width}`,
      };
    case getType(editorInvisiblesToggle):
      return {
        ...state,
        invisibles: action.payload.value,
      };
    case getType(editorDescriptionChange):
      return {
        ...state,
        description: action.payload.value,
      };
    case getType(editorStatusChange):
      return {
        ...state,
        status: action.payload.value,
      };
    case getType(editorSyncToggle):
      return {
        ...state,
        sync: action.payload.value,
      };
    case getType(editorAddClick):
      return {
        ...state,
        instances: [
          ...state.instances,
          {
            ...defaultInstance,
            key: createUniqueKey(state.instances),
          },
        ],
      };
    case getType(editorDeleteClickWithKey):
      return {
        ...state,
        instances: rejectWithKey(action.meta.key, state.instances),
      };
    case getType(editorCursorMoveWithKey):
      return mapInstanceWithKey(state, action.meta.key, instance => ({
        ...instance,
        cursor: action.payload.cursor,
      }));
    case getType(editorFilenameChangeWithKey):
      return mapInstanceWithKey(state, action.meta.key, instance => ({
        ...instance,
        filename: action.payload.value,
      }));
    case getType(editorLanguageChangeWithKey):
      return mapInstanceWithKey(state, action.meta.key, instance => ({
        ...instance,
        language: action.payload.value,
      }));
    case getType(editorIndentWithKey):
      return mapInstanceWithKey(state, action.meta.key, instance => ({
        ...instance,
        ...indent(action.payload, { tabs: state.tabs, width: state.width }),
        history: {
          ...instance.history,
          undo: instance.history.undo.concat({
            code: instance.code,
            cursor: instance.cursor,
          }),
        },
      }));
    case getType(editorMakeNewlineWithKey):
      return mapInstanceWithKey(state, action.meta.key, instance => ({
        ...instance,
        ...makeNewline(action.payload),
        history: {
          ...instance.history,
          undo: instance.history.undo.concat({
            code: instance.code,
            cursor: instance.cursor,
          }),
        },
      }));
    case getType(editorValueChangeWithKey):
      return mapInstanceWithKey(state, action.meta.key, instance => ({
        ...instance,
        code: action.payload.code,
        cursor: action.payload.cursor,
        history: {
          ...instance.history,
          undo: instance.history.undo.concat({
            code: instance.code,
            cursor: instance.cursor,
          }),
        },
      }));
    case getType(repoSaveSucceeded):
      const { response: repo } = action.payload;
      return {
        ...state,
        description: repo.description,
        status: repo.status,
        password: repo.password,
        gist_id: repo.gist_id,
        sync: repo.sync,
        instances: repo.blobs.map(blob => ({
          ...defaultInstance,
          key: blob.ID != null ? String(blob.ID) : '',
          filename: blob.filename,
          code: blob.code,
          language:
            typeof blob.language === 'string'
              ? blob.language
              : blob.language.slug,
        })),
      };
    case getType(ajaxFailed):
      return {
        ...state,
        errors: [...state.errors, action.payload.error],
      };
    case getType(init):
      return {
        ...state,
        instances:
          state.instances.length === 0
            ? defaultState.instances
            : state.instances,
      };
    default:
      return state;
  }
};

/**
 * Returns an updated array with the instance matching the provided key removed.
 *
 * @param {string} key - Key to remove.
 * @param {Instance[]} instances - Current instances
 * @returns {Instance[]} Update instances.
 */
function rejectWithKey(
  key: string | null,
  instances: Array<EditorInstance>,
): Array<EditorInstance> {
  if (key == null) {
    return instances;
  }
  return instances.filter((instance: EditorInstance) => key !== instance.key);
}

/**
 * Modify a single instance by key.
 *
 * @param {Object} state - Current state.
 * @param {string} key - Instance key to modify.
 * @param {Function} fn - Function to call
 * @returns {Object} New State.
 */
function mapInstanceWithKey(
  state: EditorState,
  key: string | null,
  fn: (i: EditorInstance) => EditorInstance,
): EditorState {
  if (key == null) {
    return state;
  }
  return {
    ...state,
    instances: state.instances.map((instance: EditorInstance) =>
      instance.key !== key ? instance : fn(instance),
    ),
  };
}

type Section = {
  before: string;
  selection: string;
  after: string;
};

/**
 * Extract code sections based on selection start & end.
 *
 * @param {string} code - Current code in editor.
 * @param {number} ss - Selection start.
 * @param {number} se - Selection end.s
 * @returns {Section} Code section.
 */
function extractSections(code: string, ss: number, se: number): Section {
  return {
    before: code.slice(0, ss),
    selection: code.slice(ss, se),
    after: code.slice(se),
  };
}

type Indentation = {
  tabs: Toggle;
  width: string;
};

/**
 * Update the code and cursor position for indentation.
 *
 * @param {string} code - Current code in the editor.
 * @param {Cursor} cursor - Cursor position.
 * @param {boolean} inverse - Whether the indentation should be inverse.
 * @param {string} tabs - Whether tabs are "on" or "off".
 * @param {string} width - Width of tabs.
 * @returns {{code: string, cursor: [number, number]}} New code and cursor position.
 */
function indent(
  { code, cursor, inverse }: EditorIndentValue,
  { tabs, width }: Indentation,
): EditorValue {
  if (!cursor) {
    return { code, cursor };
  }
  let [ss, se] = cursor;
  const { before, selection, after } = extractSections(code, ss, se);

  const w = parseInt(width, 10);
  const befores = before.split('\n');
  const rolBefore = befores.pop() || '';
  const afters = after.split('\n');
  const rolAfter = afters.shift();
  const lines = (rolBefore + selection + rolAfter).split('\n');
  const tabsEnabled = tabs === 'on';
  const append = tabsEnabled ? '\t' : new Array(w + 1).join(' ');

  for (let i = 0; i < lines.length; i++) {
    const isFirstLine = i === 0;
    const isFirstLineWithoutSelection = isFirstLine && ss === se;
    let line = lines[i];

    if (inverse) {
      if (tabsEnabled) {
        if (isFirstLineWithoutSelection && rolBefore.endsWith('\t')) {
          line =
            rolBefore.slice(0, rolBefore.length - 1) +
            line.replace(rolBefore, '');
        } else if (line.startsWith('\t')) {
          line = line.replace('\t', '');
        } else {
          break;
        }

        ss && ss--;
        se && se--;
      } else {
        let w = parseInt(width, 10);
        let newRolBefore = rolBefore;

        while (w) {
          if (
            isFirstLineWithoutSelection &&
            ' ' === newRolBefore.charAt(newRolBefore.length - 1)
          ) {
            newRolBefore = rolBefore.slice(0, newRolBefore.length - 1);

            if (!w || ' ' !== newRolBefore.charAt(newRolBefore.length - 1)) {
              ss && ss--;
              se && se--;
              line = line.replace(rolBefore, newRolBefore);
              break;
            }
          } else {
            if (!line.startsWith(' ')) {
              break;
            }
            line = line.replace(' ', '');
          }

          w--;
          ss && ss--;
          se && se--;
        }
      }
    } else {
      // If the cursor isn't selection anything on the line,
      // and the line is more than spaces or tabs to the left,
      // then we should insert the append at the cursor location.
      if (isFirstLineWithoutSelection && line.replace(/\s/g, '').length) {
        line = rolBefore + line.replace(rolBefore, append);
      } else {
        line = append + line;
      }

      if (isFirstLine) {
        ss += append.length;
      }

      se += append.length;
    }

    lines[i] = line;
  }

  return {
    code: [...befores, ...lines, ...afters].join('\n'),
    cursor: [ss, se],
  };
}

/**
 * Update the code and cursor position for newline.
 *
 * @param {string} code - Current code in the editor.
 * @param {Cursor} cursor - Cursor definition.
 * @returns {{code: string, cursor: [number, number]}} New code and cursor position.
 */
function makeNewline({ code, cursor }: EditorValue): EditorValue {
  if (!cursor) {
    return { code, cursor };
  }

  let [ss, se] = cursor;
  let { before, after } = extractSections(code, ss, se);

  const lf = before.lastIndexOf('\n') + 1;
  const indent = (before.slice(lf).match(/^\s+/) || [''])[0];

  before += '\n' + indent;

  ss += indent.length + 1;
  se = ss;

  return {
    code: before + after,
    cursor: [ss, se],
  };
}

/**
 * Creates a new unique key for the set of instances.
 *
 * @param {Instance[]} instances - Array of instances.
 * @returns {string} New unique key.
 */
function createUniqueKey(instances: Array<EditorInstance>): string {
  const keys = instances.map(({ key }) => key);

  let id = 0;

  while (true) {
    const key = 'new' + id;

    if (keys.indexOf(key) === -1) {
      return key;
    }

    id++;
  }
}