src/components/TodoEditor.tsx

Summary

Maintainability
D
2 days
Test Coverage
import React, { useState, useEffect, useRef } from 'react'
import TextareaAutosize from 'react-textarea-autosize'

import { useTaskManager } from '@/hooks/useTaskManager'
import { useStorageWatcher } from '@/hooks/useStorageWatcher'
import { useTaskRecordKey } from '@/hooks/useTaskRecordKey'
import { useEventAlarm } from '@/hooks/useEventAlarm'
import { useAnalytics } from '@/hooks/useAnalytics'
import { Task } from '@/models/task'
import { Group } from '@/models/group'
import { LoadingIcon } from '@/components/LoadingIcon'
import {
  sleep,
  getIndent,
  depthToIndent,
  getIndentDepth,
} from '@/services/util'
import * as i18n from '@/services/i18n'
import { INDENT_SIZE, KEY, KEYCODE_ENTER, TASK_DEFAULT } from '@/const'
import Log from '@/services/log'

const INDENT = depthToIndent(1)

type Selection = {
  start: number
  end: number
}

export function TodoEditor(): JSX.Element {
  const manager = useTaskManager()
  const rootText = manager.getText()
  const [saving] = useStorageWatcher()
  const [text, setText] = useState('')
  const [timeoutID, setTimeoutID] = useState<number>()
  const [iconVisible, setIconVisible] = useState(false)
  const [iconHidden, setIconHidden] = useState(true)
  const [selection, setSelection] = useState<Selection>()
  const { currentKey } = useTaskRecordKey()
  const { fixEventLines } = useEventAlarm()
  const inputArea = useRef<HTMLTextAreaElement>()
  const analytics = useAnalytics()

  useEffect(() => {
    setText(rootText)
  }, [currentKey, rootText])

  useEffect(() => {
    setIconHidden(true)
    sleep(2000).then(() => setIconHidden(false))
  }, [currentKey])

  useEffect(() => {
    let unmounted = false
    if (timeoutID) clearTimeout(timeoutID)
    const newTimeoutId = window.setTimeout(() => {
      if (unmounted) return
      manager.setText(text)
      setTimeoutID(null)
    }, 1 * 500 /* ms */)

    setTimeoutID(newTimeoutId)

    return () => {
      unmounted = true
      clearTimeout(timeoutID)
    }
  }, [text])

  useEffect(() => {
    if (selection && selection.start && selection.end) {
      inputArea.current.setSelectionRange(selection.start, selection.end)
    }
  }, [selection])

  useEffect(() => {
    async function updateVisible() {
      if (saving) {
        setIconVisible(true)
        await sleep(500)
        setIconVisible(false)
      }
    }
    void updateVisible()
  }, [saving])

  const onChange = ({ target: { value } }) => {
    setText(value)
  }

  const onBlur = () => {
    // Update the global scope data.
    const newRoot = manager.setText(text)
    fixEventLines(newRoot)
  }

  /**
   * Add/Remove indent.
   * @param depth Indent depth.
   * @param from Start row.
   * @param to End row.
   * @returns changed lines.
   */
  const changeIndent = (
    depth: number,
    from: number,
    to: number,
  ): [string, number, number] => {
    const lines = text.split(/\n/)
    const chagnedLines = []
    const increse = depth > 0
    depth = Math.abs(depth)
    let fromMoved = 0
    let toMoved = 0
    for (let i = from - 1; i <= to - 1; i++) {
      if (increse) {
        // Increase indent.
        const indent = depthToIndent(depth)
        chagnedLines.push(indent + lines[i])
        if (i === from - 1) fromMoved = indent.length
        toMoved += indent.length
      } else {
        // Decrease indent.
        const currentLine = lines[i]
        const currentDepth = getIndentDepth(currentLine)
        let d = depth
        if (currentDepth === 0) {
          d = 0
        } else if (currentDepth < depth) {
          d = currentDepth
        }
        chagnedLines.push(currentLine.slice(INDENT_SIZE * d))
        if (i === from - 1) fromMoved -= INDENT_SIZE * d
        toMoved -= INDENT_SIZE * d
      }
    }
    return [chagnedLines.join('\n'), fromMoved, toMoved]
  }

  /**
   * Calculate the start position of the row.
   * @param row Target row number.
   * @param lines
   * @returns Start position of the row
   */
  const calcRowStartPos = (row: number, lines: string[]): number => {
    if (row === 1) return 0
    return lines.slice(0, row - 1).join('\n').length + 1
  }

  /**
   * Calculate the end position of the row.
   * @param row Target row number.
   * @param lines
   * @returns End position of the row.
   */
  const calcRowEndPos = (row: number, lines: string[]): number => {
    let nl = 1
    if (row === 1) nl--
    return (
      lines.slice(0, row - 1).join('\n').length + lines[row - 1].length + nl
    )
  }

  /**
   * Set text at selected rows.
   * @param from Selected start row nubmer.
   * @param to Selected end row number.
   * @param newText Text to be set.
   * @param appendRow If true, append a new row at the end of the selected rows.
   */
  function setTextAtRow(
    from: number,
    to: number,
    newText: string,
    appendRow = false,
  ) {
    const lines = text.split(/\n/)
    if (appendRow) {
      newText = '\n' + newText
      const start = calcRowEndPos(from, lines)
      const end = calcRowEndPos(to, lines)
      inputArea.current.setSelectionRange(start, end)
    } else {
      const start = calcRowStartPos(from, lines)
      const end = calcRowEndPos(to, lines)
      inputArea.current.setSelectionRange(start, end)
    }

    let executed = true
    try {
      if (!document.execCommand('insertText', false, newText)) {
        executed = false
      }
    } catch (e) {
      analytics.track('ErrorInsertText')
      Log.e('error caught:', e)
      executed = false
    }
    if (!executed) {
      Log.e('insertText unsuccessful, execCommand not supported')
    }
  }

  function onKeyDown(e: React.KeyboardEvent) {
    if (e.keyCode === KEYCODE_ENTER) {
      // KeyCode is used to distinguish it from the Enter key input during IME
      const start = inputArea.current.selectionStart
      const end = inputArea.current.selectionEnd
      if (start !== end) return
      if (e.shiftKey) return

      const lines = text.split(/\n/)
      let currentRow = text.slice(0, start).split(/\n/).length
      const currentLine = lines[currentRow - 1]
      if (Task.isEmptyTask(currentLine)) {
        const depth = getIndentDepth(currentLine)
        if (depth > 0) {
          // Decrease the indent of the current line.
          let replaceLine = depthToIndent(Math.max(depth - 1, 0)) + TASK_DEFAULT
          setTextAtRow(currentRow, currentRow, replaceLine)
        } else {
          // Set the current line to empty.
          let replaceLine = ''
          if (start === inputArea.current.value.length) {
            // The cause is unclear, but if you perform an operation to leave the last line empty,
            // the focus position gets messed up, so insert one line break.
            replaceLine = '\n'
          }
          setTextAtRow(currentRow, currentRow, replaceLine)
        }
      } else if (Task.test(currentLine)) {
        // Add a new task as sibling level.
        const addedLine = getIndent(currentLine) + TASK_DEFAULT
        setTextAtRow(currentRow, currentRow, addedLine, true)
        currentRow++
      } else if (Group.test(currentLine)) {
        // Add a new task as child level.
        const addedLine = getIndent(currentLine) + INDENT + TASK_DEFAULT
        setTextAtRow(currentRow, currentRow, addedLine, true)
        currentRow++
      } else {
        // Add a empty line (default).
        return
      }

      const newPos = inputArea.current.value
        .split(/\n/)
        .slice(0, currentRow)
        .join('\n').length
      setSelection({ start: newPos, end: newPos })

      e.preventDefault()
      return
    }

    switch (e.code) {
      case KEY.TAB: {
        const start = inputArea.current.selectionStart
        const from = text.slice(0, start).split(/\n/).length
        const end = inputArea.current.selectionEnd
        const to = text.slice(0, end).split(/\n/).length

        let [newText, fromMoved, toMoved] = changeIndent(
          e.shiftKey ? -1 : 1,
          from,
          to,
        )
        setTextAtRow(from, to, newText)
        setSelection({
          start: start + fromMoved,
          end: end + toMoved,
        })

        e.preventDefault()
        break
      }
    }
  }

  return (
    <div className="task-textarea">
      {iconVisible && !iconHidden ? (
        <LoadingIcon>
          <span>{i18n.t('saving')}</span>
        </LoadingIcon>
      ) : null}
      <TextareaAutosize
        className=""
        onChange={onChange}
        onBlur={onBlur}
        onKeyDown={onKeyDown}
        ref={inputArea}
        defaultValue={text}
      ></TextareaAutosize>
    </div>
  )
}