Source: editor.mjs

/* global EasyMDE */
import { markdown2HTML, generateMaps } from './dumbymap'
import { defaultAliases, parseConfigsFromYaml } from 'mapclay'
import * as menuItem from './MenuItem'
/* eslint-disable-next-line no-unused-vars */
import { addMarkerByPoint } from './dumbyUtils.mjs'
import { shiftByWindow } from './utils.mjs'
import * as tutorial from './tutorial'

/**
 * @typedef {Object} RefLink
 * @property {string} ref - name of link
 * @property {string} link - content of link
 * @property {string|null} title - title of link
 */

// Set up Containers {{{

/** Variables: page */
const url = new URL(window.location)
const pageParams = url.searchParams
const crs = pageParams.get('crs') ?? 'EPSG:4326'
const initialLayout = pageParams.get('layout')

/** Variables: dumbymap and editor **/
const context = document.querySelector('[data-mode]')
const textArea = document.querySelector('.editor textarea')
const dumbyContainer = document.querySelector('.DumbyMap')
dumbyContainer.dataset.scrollLine = ''
/** Watch: DumbyMap */
new window.MutationObserver(mutations => {
  const mutation = mutations.at(-1)

  // Handle layout change
  const layout = dumbyContainer.dataset.layout
  if (layout && (layout !== 'normal' || mutation.oldValue === 'normal')) {
    context.dataset.mode = ''
  }
}).observe(dumbyContainer, {
  attributes: true,
  attributeFilter: ['data-layout'],
  attributeOldValue: true,
})
const dumbymap = generateMaps(dumbyContainer, { crs, initialLayout })
// Set oncontextmenu callback
dumbymap.utils.setContextMenu(menuForEditor)

/** Variables: Reference Style Links in Markdown */
const refLinkPattern = /\[([^\x5B\x5D]+)\]:\s+(\S+)(\s["'](\S+)["'])?/
let refLinks = []

/**
 * Appends a reference link to the CodeMirror instance
 *
 * @param {CodeMirror} cm - The CodeMirror instance
 * @param {RefLink} refLink - The reference link to append
 */
/* eslint-disable-next-line no-unused-vars */
const appendRefLink = (cm, refLink) => {
  editor.dataset.update = 'false'

  const { ref, link, title } = refLink
  let refLinkString = `\n[${ref}]: ${link} "${title ?? ''}"`
  const lastLineIsRefLink = cm.getLine(cm.lastLine()).match(refLinkPattern)
  if (!lastLineIsRefLink) refLinkString = '\n' + refLinkString
  cm.replaceRange(refLinkString, { line: Infinity })

  refLinks.push(refLink)
}
/**
 * Watch for changes of editing mode
 *
 * For 'data-mode' attribute of the context element, if the mode is 'editing'
 * and the layout is not 'normal', it sets the layout to 'normal' and switch to editing mode
 */
new window.MutationObserver(() => {
  const mode = context.dataset.mode
  const layout = dumbyContainer.dataset.layout
  if (mode === 'editing' && layout !== 'normal') {
    dumbyContainer.dataset.layout = 'normal'
  }
}).observe(context, {
  attributes: true,
  attributeFilter: ['data-mode'],
  attributeOldValue: true,
})
/**
 * Toggles the editing mode
 */
const toggleEditing = () => {
  const mode = context.dataset.mode
  if (mode === 'editing') {
    context.dataset.mode = ''
  } else {
    context.dataset.mode = 'editing'
  }
}
// }}}
// Set up EasyMDE {{{
/** Editor from EasyMDE **/
const editor = new EasyMDE({
  element: textArea,
  initialValue: tutorial.md,
  autosave: {
    enabled: true,
    uniqueId: 'dumbymap',
  },
  indentWithTabs: false,
  lineNumbers: true,
  promptURLs: true,
  uploadImage: true,
  spellChecker: false,
  toolbarButtonClassPrefix: 'mde',
  status: false,
  shortcuts: {
    map: 'Ctrl-Alt-M',
    debug: 'Ctrl-Alt-D',
    toggleUnorderedList: null,
    toggleOrderedList: null,
  },
  toolbar: [
    {
      name: 'roll',
      title: 'Roll a Dice',
      text: '\u{2684}',
      action: () => addMapRandomlyByPreset(),
    },
    {
      name: 'export',
      title: 'Export current page',
      text: '\u{1F4BE}',
      action: () => {
      },
    },
    {
      name: 'hash',
      title: 'Save content as URL',
      // text: '\u{1F4BE}',
      text: '#',
      action: () => {
        const state = { content: editor.value() }
        window.location.hash = encodeURIComponent(JSON.stringify(state))
        window.location.search = ''
        navigator.clipboard.writeText(window.location.href)
        window.alert('URL updated in address bar, you can save current page as bookmark')
      },
    },
    '|',
    {
      name: 'undo',
      title: 'Undo last editing',
      text: '\u27F2',
      action: EasyMDE.undo,
    },
    {
      name: 'redo',
      text: '\u27F3',
      title: 'Redo editing',
      action: EasyMDE.redo,
    },
    '|',
    {
      name: 'heading-1',
      text: 'H1',
      title: 'Big Heading',
      action: EasyMDE['heading-1'],
    },
    {
      name: 'heading-2',
      text: 'H2',
      title: 'Medium Heading',
      action: EasyMDE['heading-2'],
    },
    '|',
    {
      name: 'link',
      text: '\u{1F517}',
      title: 'Create Link',
      action: EasyMDE.drawLink,
    },
    {
      name: 'image',
      text: '\u{1F5BC}',
      title: 'Create Image',
      action: EasyMDE.drawImage,
    },
    '|',
    {
      name: 'Bold',
      text: '\u{1D401}',
      title: 'Bold',
      action: EasyMDE.toggleBold,
    },
    {
      name: 'Italic',
      text: '\u{1D43C}',
      title: 'Italic',
      action: EasyMDE.toggleItalic,
    },
    '|',
    {
      name: 'tutorial',
      text: '\u{2753}',
      title: 'Reset contents by tutorial',
      action: () => {
        editor.value(tutorial.md)
        refLinks = getRefLinks()
        cm.focus()
        cm.setCursor({ line: 0, ch: 0 })
      },
    },
  ],
})
/** CodeMirror Instance **/
const cm = editor.codemirror

/**
 * getRefLinks from contents of editor
 * @return {RefLink[]} refLinks
 */
const getRefLinks = () => editor.value()
  .split('\n')
  .map(line => {
    const [, ref, link,, title] = line.match(refLinkPattern) ?? []
    return { ref, link, title }
  })
  .filter(({ ref, link }) => ref && link)

refLinks = getRefLinks()

/**
 * get state of website from hash string
 *
 * @param {String} hash
 */
const getStateFromHash = hash => {
  const hashValue = hash.substring(1)
  const stateString = decodeURIComponent(hashValue)
  try {
    return JSON.parse(stateString) ?? {}
  } catch (_) {
    return {}
  }
}
/**
 * get editor content from hash string
 *
 * @param {String} hash
 */
const getContentFromHash = hash => {
  const state = getStateFromHash(hash)
  return state.content
}
/** Hash and Query Parameters in URL **/
const contentFromHash = getContentFromHash(window.location.hash)
window.location.hash = ''

if (url.searchParams.get('content') === 'tutorial') {
  editor.value(tutorial.md)
} else if (contentFromHash) {
  // Seems like autosave would overwrite initialValue, set content from hash here
  editor.cleanup()
  editor.value(contentFromHash)
}
// }}}
// Set up logic about editor content {{{

/**
 * updateScrollLine. Update data attribute by scroll on given element
 *
 * @param {HTMLElement} ele
 */
const updateScrollLine = (ele) => () => {
  if (textArea.dataset.scrollLine) return

  const threshold = ele.scrollTop + window.innerHeight / 2 + 30
  const block = Array.from(ele.children)
    .findLast(e => e.offsetTop < threshold) ??
    ele.firstChild

  const line = Array.from(block.querySelectorAll('p'))
    .findLast(e => e.offsetTop + block.offsetTop < threshold)
  const linenumber = line?.dataset?.sourceLine
  if (!linenumber) return
  const offset = (line.offsetTop + block.offsetTop - ele.scrollTop)

  if (linenumber) {
    ele.closest('[data-scroll-line]').dataset.scrollLine = linenumber + '/' + offset
  }
}

/** sync scroll from HTML to CodeMirror */
new window.MutationObserver(() => {
  clearTimeout(dumbyContainer.timer)
  dumbyContainer.timer = setTimeout(
    () => { dumbyContainer.dataset.scrollLine = '' },
    50,
  )

  const line = dumbyContainer.dataset.scrollLine
  if (line) {
    const [lineNumber, offset] = line.split('/')

    if (!lineNumber || isNaN(lineNumber)) return

    cm.scrollIntoView({ line: lineNumber, ch: 0 }, offset)
  }
}).observe(dumbyContainer, {
  attributes: true,
  attributeFilter: ['data-scroll-line'],
})

/**
 * updateScrollLineByCodeMirror.
 * @param {CodeMirror} cm
 */
const updateCMScrollLine = (cm) => {
  if (dumbyContainer.dataset.scrollLine) return

  const lineNumber = cm.getCursor()?.line ??
    cm.lineAtHeight(cm.getScrollInfo().top, 'local')
  textArea.dataset.scrollLine = lineNumber
}
cm.on('scroll', () => {
  if (cm.hasFocus()) updateCMScrollLine(cm)
})

/** Sync scroll from CodeMirror to HTML **/
new window.MutationObserver(() => {
  clearTimeout(textArea.timer)
  textArea.timer = setTimeout(
    () => { textArea.dataset.scrollLine = '' },
    1000,
  )

  const line = textArea.dataset.scrollLine
  let lineNumber = Number(line)
  let p
  if (!line || isNaN(lineNumber)) return

  const paragraphs = Array.from(dumbymap.htmlHolder.querySelectorAll('p'))
  do {
    p = paragraphs.find(p => Number(p.dataset.sourceLine) === lineNumber)
    lineNumber++
  } while (!p && lineNumber < cm.doc.size)
  p = p ?? paragraphs.at(-1)
  if (!p) return

  const coords = cm.charCoords({ line: lineNumber, ch: 0 })
  p.scrollIntoView({ inline: 'start' })
  const top = p.getBoundingClientRect().top
  dumbymap.htmlHolder.scrollBy(0, top - coords.top + 30)
}).observe(textArea, {
  attributes: true,
  attributeFilter: ['data-scroll-line'],
})

/**
 * addClassToCodeLines. Quick hack to style lines inside code block
 */
const addClassToCodeLines = () => {
  const lines = cm.getLineHandle(0).parent.lines
  let insideCodeBlock = false
  lines.forEach((line, index) => {
    if (line.text.match(/^[\u0060]{3}/)) {
      insideCodeBlock = !insideCodeBlock
    } else if (insideCodeBlock) {
      cm.addLineClass(index, 'text', 'inside-code-block')
    } else {
      cm.removeLineClass(index, 'text', 'inside-code-block')
    }
  })
}
addClassToCodeLines()

/**
 * completeForCodeBlock.
 *
 * @param {Object} change - codemirror change object
 */
const completeForCodeBlock = change => {
  const line = change.to.line
  if (change.origin === '+input') {
    const text = change.text[0]

    // Completion for YAML doc separator
    if (
      text === '-' &&
      change.to.ch === 0 &&
      insideCodeblockForMap(cm.getCursor())
    ) {
      cm.setSelection({ line, ch: 0 }, { line, ch: 1 })
      cm.replaceSelection(text.repeat(3) + '\n')
    }

    // Completion for Code fence
    if (text === '`' && change.to.ch === 0) {
      cm.setSelection({ line, ch: 0 }, { line, ch: 1 })
      cm.replaceSelection(text.repeat(3))
      const numberOfFences = cm
        .getValue()
        .split('\n')
        .filter(line => line.match(/[\u0060]{3}/)).length
      if (numberOfFences % 2 === 1) {
        cm.replaceSelection('map\n\n```')
        cm.setCursor({ line: line + 1 })
      }
    }
  }

  // For YAML doc separator, <hr> and code fence
  // Auto delete to start of line
  if (change.origin === '+delete') {
    const match = change.removed[0].match(/^[-\u0060]$/)?.at(0)
    if (match && cm.getLine(line) === match.repeat(2) && match) {
      cm.setSelection({ line, ch: 0 }, { line, ch: 2 })
      cm.replaceSelection('')
    }
  }
}

/**
 * menuForEditor.
 *
 * @param {Event} event - Event for context menu
 * @param {HTMLElement} menu - menu of dumbymap
 */
function menuForEditor (event, menu) {
  event.preventDefault()

  if (document.getSelection().type === 'Range' && cm.getSelection() && refLinks.length > 0) {
    menu.replaceChildren()
    menu.appendChild(menuItem.addRefLink(cm, refLinks))
  }

  if (context.dataset.mode !== 'editing') {
    const switchToEditingMode = menuItem.Item({
      innerHTML: '<strong>EDIT</strong>',
      onclick: () => (context.dataset.mode = 'editing'),
    })
    menu.appendChild(switchToEditingMode)
  }

  // const map = event.target.closest('.mapclay')
  // if (map) {
  //   const item = menuItem.Item({
  //     text: 'Add Anchor',
  //     onclick: () => {
  //       let anchorName
  //       do {
  //         anchorName = window.prompt(anchorName ? 'Name exists' : 'Name of Anchor')
  //       } while (refLinks.find(ref => ref === anchorName))
  //       if (anchorName === null) return
  //
  //       const marker = addMarkerByPoint({ point: [event.clientX, event.clientY], map })
  //       const refLink = {
  //         ref: anchorName,
  //         link: `geo:${marker.dataset.xy.split(',').reverse()}`,
  //       }
  //       appendRefLink(cm, refLink)
  //     },
  //   })
  //   menu.insertBefore(item, menu.firstChild)
  // }
}

/**
 * update content of HTML about Dumbymap
 */
const updateDumbyMap = (callback = null) => {
  markdown2HTML(dumbyContainer, editor.value())
  // Set onscroll callback
  const htmlHolder = dumbymap.htmlHolder
  dumbymap.htmlHolder.onscroll = updateScrollLine(htmlHolder)

  callback?.(dumbymap)
}
updateDumbyMap()

// Re-render HTML by editor content
cm.on('change', (_, change) => {
  if (editor.dataset?.update !== 'false') {
    textArea.dataset.scrollLine = cm.getCursor().line
    updateDumbyMap(() => {
      updateCMScrollLine(cm)
    })
  } else {
    delete editor.dataset.update
  }
  addClassToCodeLines()
  completeForCodeBlock(change)
})

// Set class for focus
cm.on('focus', () => {
  cm.getWrapperElement().classList.add('focus')
  dumbyContainer.classList.remove('focus')
})

cm.on('beforeChange', (_, change) => {
  // Don't allow more content after YAML doc separator
  if (change.origin && change.origin.match(/^(\+input|paste)$/)) {
    const line = change.to.line
    if (cm.getLine(line) === '---' && change.text[0] !== '') {
      change.cancel()
    }
  }
})

// Reload editor content by hash value
window.onhashchange = () => {
  const content = getContentFromHash(window.location.hash)
  if (content) editor.value(content)
}

// }}}
// Completion in Code Blok {{{
// Elements about suggestions {{{
const menu = document.createElement('div')
menu.className = 'menu editor-menu'
menu.style.display = 'none'
menu.onclick = () => (menu.style.display = 'none')
new window.MutationObserver(() => {
  if (menu.style.display === 'none') {
    menu.replaceChildren()
  }
}).observe(menu, {
  attributes: true,
  attributeFilter: ['style'],
})
document.body.append(menu)

const rendererOptions = {}

// }}}
// Aliases for map options {{{
const aliasesForMapOptions = {}
const defaultApply = './assets/default.yml'
fetch(defaultApply)
  .then(res => res.text())
  .then(rawText => {
    const config = parseConfigsFromYaml(rawText)?.at(0)
    Object.assign(aliasesForMapOptions, config.aliases ?? {})
  })
  .catch(err => console.warn(`Fail to get aliases from ${defaultApply}`, err))
// }}}
/**
 * insideCodeblockForMap. Check if current token is inside code block {{{
 *
 * @param {Anchor} anchor
 */
const insideCodeblockForMap = anchor => {
  const token = cm.getTokenAt(anchor)
  const insideCodeBlock =
    token.state.overlay.codeBlock &&
    !cm.getLine(anchor.line).match(/^[\u0060]{3}/)
  if (!insideCodeBlock) return false

  let line = anchor.line - 1
  while (line >= 0) {
    const content = cm.getLine(line)
    if (content === '```map') {
      return true
    } else if (content === '```') {
      return false
    }
    line = line - 1
  }
  return false
}
// }}}
/**
 * getLineWithRenderer. Get Renderer by cursor position in code block {{{
 *
 * @param {Object} anchor - Codemirror Anchor Object
 */
const getLineWithRenderer = anchor => {
  const currentLine = anchor.line
  if (!cm.getLine) return null

  const match = line => cm.getLine(line).match(/^use: /)

  if (match(currentLine)) return currentLine

  // Look backward/forward for pattern of used renderer: /use: .+/
  let pl = currentLine - 1
  while (pl > 0 && insideCodeblockForMap(anchor)) {
    const text = cm.getLine(pl)
    if (match(pl)) {
      return pl
    } else if (text.match(/^---|^[\u0060]{3}/)) {
      break
    }
    pl = pl - 1
  }

  let nl = currentLine + 1
  while (insideCodeblockForMap(anchor)) {
    const text = cm.getLine(nl)
    if (match(nl)) {
      return nl
    } else if (text.match(/^---|^[\u0060]{3}/)) {
      return null
    }
    nl = nl + 1
  }

  return null
}
// }}}
/**
 * getSuggestionsForOptions. Return suggestions for valid options {{{
 *
 * @param {Boolean} optionTyped
 * @param {Object[]} validOptions
 */
const getSuggestionsForOptions = (optionTyped, validOptions) => {
  let suggestOptions = []

  const matchedOptions = validOptions.filter(o =>
    o.valueOf().toLowerCase().includes(optionTyped.toLowerCase()),
  )

  if (matchedOptions.length > 0) {
    suggestOptions = matchedOptions
  } else {
    suggestOptions = validOptions
  }

  return suggestOptions.map(
    o =>
      menuItem.Suggestion({
        text: `<span>${o.valueOf()}</span><span class='info' title="${o.desc ?? ''}">ⓘ</span>`,
        replace: `${o.valueOf()}: `,
        cm,
      }),
  )
}
// }}}
/**
 * getSuggestionFromMapOption. Return suggestion for example of option value {{{
 *
 * @param {Object} option
 */
const getSuggestionFromMapOption = option => {
  if (!option.example) return null

  const text = option.example_desc
    ? `<span>${option.example_desc}</span><span class="truncate"style="color: gray">${option.example}</span>`
    : `<span>${option.example}</span>`

  return menuItem.Suggestion({
    text,
    replace: `${option.valueOf()}: ${option.example ?? ''}`,
    cm,
  })
}
// }}}
/**
 * getSuggestionsFromAliases. Return suggestions from aliases {{{
 *
 * @param {Object} option
 */
const getSuggestionsFromAliases = option =>
  Object.entries(aliasesForMapOptions[option.valueOf()] ?? {})?.map(record => {
    const [alias, value] = record
    const valueString = JSON.stringify(value).replaceAll('"', '')
    return menuItem.Suggestion({
      text: `<span>${alias}</span><span class="truncate" style="color: gray">${valueString}</span>`,
      replace: `${option.valueOf()}: ${valueString}`,
      cm,
    })
  }) ?? []
// }}}
/**
 * handleTypingInCodeBlock. Handler for map codeblock {{{
 *
 * @param {Object} anchor - Codemirror Anchor Object
 */
const handleTypingInCodeBlock = anchor => {
  const text = cm.getLine(anchor.line)
  if (text.match(/^\s\+$/) && text.length % 2 !== 0) {
    // TODO Completion for even number of spaces
  } else if (text.match(/^-/)) {
    // TODO Completion for YAML doc separator
  } else {
    const suggestions = getSuggestions(anchor)
    addSuggestions(anchor, suggestions)
  }
}
// }}}
/**
 * getSuggestions. Get suggestions by current input {{{
 *
 * @param {Object} anchor - Codemirror Anchor Object
 */
const getSuggestions = anchor => {
  const text = cm.getLine(anchor.line)

  // Clear marks on text
  cm.findMarks({ ...anchor, ch: 0 }, { ...anchor, ch: text.length }).forEach(
    m => m.clear(),
  )

  // Mark user input invalid by case
  const markInputIsInvalid = () =>
    cm
      .getDoc()
      .markText(
        { ...anchor, ch: 0 },
        { ...anchor, ch: text.length },
        { className: 'invalid-input' },
      )

  // Check if "use: <renderer>" is set
  const lineWithRenderer = getLineWithRenderer(anchor)
  const renderer = lineWithRenderer
    ? cm.getLine(lineWithRenderer).split(' ')[1]
    : null
  if (renderer && anchor.line !== lineWithRenderer) {
    // Do not check properties
    if (text.startsWith('  ')) return []

    // If no valid options for current used renderer, go get it!
    const validOptions = rendererOptions[renderer]
    if (!validOptions) {
      // Get list of valid options for current renderer
      const rendererUrl = defaultAliases.use[renderer]?.value
      import(rendererUrl)
        .then(rendererModule => {
          rendererOptions[renderer] = rendererModule.default.validOptions
          const currentAnchor = cm.getCursor()
          if (insideCodeblockForMap(currentAnchor)) {
            handleTypingInCodeBlock(currentAnchor)
          }
        })
        .catch(_ => {
          markInputIsInvalid(lineWithRenderer)
          console.warn(
            `Fail to get valid options from Renderer typed: ${renderer}`,
          )
        })
      return []
    }

    // If input is "key:value" (no space left after colon), then it is invalid
    const isKeyFinished = text.includes(':')
    const isValidKeyValue = text.match(/^[^:]+:\s+/)
    if (isKeyFinished && !isValidKeyValue) {
      markInputIsInvalid()
      return []
    }

    // If user is typing option
    const keyTyped = text.split(':')[0].trim()
    if (!isKeyFinished) {
      markInputIsInvalid()
      return getSuggestionsForOptions(keyTyped, validOptions)
    }

    // If user is typing value
    const matchedOption = validOptions.find(o => o.name === keyTyped)
    if (isKeyFinished && !matchedOption) {
      markInputIsInvalid()
    }

    if (isKeyFinished && matchedOption) {
      const valueTyped = text.substring(text.indexOf(':') + 1).trim()
      const isValidValue = matchedOption.isValid(valueTyped)
      if (!valueTyped) {
        return [
          getSuggestionFromMapOption(matchedOption),
          ...getSuggestionsFromAliases(matchedOption),
        ].filter(s => s instanceof menuItem.Suggestion)
      }
      if (valueTyped && !isValidValue) {
        markInputIsInvalid()
        return []
      }
    }
  } else {
    // Suggestion for "use"
    const rendererSuggestions = Object.entries(defaultAliases.use)
      .filter(([renderer]) => {
        const suggestion = `use: ${renderer}`
        const suggestionPattern = suggestion.replace(' ', '').toLowerCase()
        const textPattern = text.replace(' ', '').toLowerCase()
        return suggestion !== text && suggestionPattern.includes(textPattern)
      })
      .map(
        ([renderer, info]) =>
          menuItem.Suggestion({
            text: `<span>use: ${renderer}</span><span class='info' title="${info.desc}">ⓘ</span>`,
            replace: `use: ${renderer}`,
            cm,
          }),
      )
    return rendererSuggestions.length === 0
      ? []
      : [
          ...rendererSuggestions,
          menuItem.Item({
            innerHTML: '<a href="https://github.com/outdoorsafetylab/mapclay#renderer" class="external" style="display: block;">More...</a>',
            className: ['suggestion'],
            onclick: () => window.open('https://github.com/outdoorsafetylab/mapclay#renderer', '_blank'),
          }),
        ]
  }
  return []
}
// }}}
/**
 * addSuggestions.  Show element about suggestions {{{
 *
 * @param {Object} anchor - Codemirror Anchor Object
 * @param {Suggestion[]} suggestions
 */
const addSuggestions = (anchor, suggestions) => {
  if (suggestions.length === 0) {
    menu.style.display = 'none'
    return
  } else {
    menu.style.display = 'block'
  }

  menu.innerHTML = ''
  suggestions
    .forEach(option => menu.appendChild(option))

  const widgetAnchor = document.createElement('div')
  cm.addWidget(anchor, widgetAnchor, true)
  const rect = widgetAnchor.getBoundingClientRect()
  menu.style.left = `calc(${rect.left}px + 2rem)`
  menu.style.top = `calc(${rect.bottom}px + 1rem)`
  menu.style.display = 'block'
  shiftByWindow(menu)
}
// }}}
// EVENT: Suggests for current selection {{{
// FIXME Dont show suggestion when selecting multiple chars
cm.on('cursorActivity', _ => {
  menu.style.display = 'none'
  const anchor = cm.getCursor()

  if (insideCodeblockForMap(anchor)) {
    handleTypingInCodeBlock(anchor)
  }
})
cm.on('blur', () => {
  refLinks = getRefLinks()

  if (menu.checkVisibility()) {
    cm.focus()
  } else {
    cm.getWrapperElement().classList.remove('focus')
    dumbyContainer.classList.add('focus')
  }
})
// }}}
// EVENT: keydown for suggestions {{{
const keyForSuggestions = ['Tab', 'Enter', 'Escape']
cm.on('keydown', (_, e) => {
  if (
    !cm.hasFocus ||
    !keyForSuggestions.includes(e.key) ||
    menu.style.display === 'none'
  ) { return }

  // Directly add a newline when no suggestion is selected
  const currentSuggestion = menu.querySelector('.menu-item.focus')
  if (!currentSuggestion && e.key === 'Enter') return

  // Override default behavior
  e.preventDefault()

  // Suggestion when pressing Tab or Shift + Tab
  const nextSuggestion =
    currentSuggestion?.nextSibling ??
    menu.querySelector('.menu-item:first-child')
  const previousSuggestion =
    currentSuggestion?.previousSibling ??
    menu.querySelector('.menu-item:last-child')
  const focusSuggestion = e.shiftKey ? previousSuggestion : nextSuggestion

  // Current editor selection state
  switch (e.key) {
    case 'Tab':
      Array.from(menu.children).forEach(s => s.classList.remove('focus'))
      focusSuggestion.classList.add('focus')
      focusSuggestion.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
      break
    case 'Enter':
      currentSuggestion.onclick()
      break
    case 'Escape':
      if (!menu.checkVisibility()) break
      // HACK delay menu display change for blur event, mark cm focus should keep
      setTimeout(() => (menu.style.display = 'none'), 50)
      break
  }
})

document.onkeydown = e => {
  if (document.activeElement.matches('textarea')) return null
  if (e.altKey && e.ctrlKey && e.key === 'm') {
    toggleEditing()
    e.preventDefault()
    return null
  }

  if (!cm.hasFocus()) {
    if (e.key === 'F1') {
      e.preventDefault()
      cm.focus()
    }
    if (e.key === 'Tab') {
      e.preventDefault()
      dumbymap.utils.focusNextMap(e.shiftKey)
    }
    if (e.key === 'x' || e.key === 'X') {
      e.preventDefault()
      dumbymap.utils.switchToNextLayout(e.shiftKey)
    }
    if (e.key === 'n') {
      e.preventDefault()
      dumbymap.utils.focusNextBlock()
    }
    if (e.key === 'p') {
      e.preventDefault()
      dumbymap.utils.focusNextBlock(true)
    }
    if (e.key === 'Escape') {
      e.preventDefault()
      dumbymap.utils.removeBlockFocus()
    }
  }
}

// }}}
// }}}
/**
 * addMapRandomlyByPreset. insert random text of valid mapclay yaml into editor
 */
const addMapRandomlyByPreset = () => {
  const yamlText = [
    'apply: ./assets/default.yml',
    'width: 85%',
    'height: 200px',
  ]
  const order = [
    'id',
    'apply',
    'use',
    'width',
    'height',
    'center',
    'XYZ',
    'zoom',
  ]
  const aliasesEntries = Object.entries(aliasesForMapOptions)
    .filter(([key, _]) =>
      order.includes(key) &&
      !yamlText.find(text => text.startsWith(key)),
    )
  if (aliasesEntries.length === 0) return

  aliasesEntries.forEach(([option, aliases]) => {
    const entries = Object.entries(aliases)
    const validEntries = entries
      .filter(([alias, value]) => {
        // FIXME logic about picking XYZ data
        if (option === 'XYZ') {
          const inTaiwan = yamlText.find(text => text.match(/center: TAIWAN/))
          if (!inTaiwan) return !alias.includes('TAIWAN')
        }
        if (option === 'zoom') {
          return value > 6 && value < 15
        }
        return true
      })
    const randomValue = validEntries
      .at((Math.random() * validEntries.length) | 0)
      .at(0)

    yamlText.push(`${option}: ${typeof randomValue === 'object' ? randomValue.value : randomValue}`)

    if (option === 'center') yamlText.push(`id: ${randomValue}`)
  })

  yamlText.sort((a, b) =>
    order.indexOf(a.split(':')[0]) > order.indexOf(b.split(':')[0]),
  )
  const anchor = cm.getCursor()
  cm.replaceRange(
    '\n```map\n' + yamlText.join('\n') + '\n```\n',
    anchor,
  )
}

cm.getWrapperElement().oncontextmenu = e => {
  if (insideCodeblockForMap(cm.getCursor())) return
  e.preventDefault()

  if (cm.getSelection() && refLinks.length > 0) {
    menu.appendChild(menuItem.addRefLink(cm, refLinks))
  }

  if (menu.children.length > 0) {
    menu.style.cssText = `display: block; transform: translate(${e.x}px, ${e.y}px); overflow: visible;`
  }
}

/** HACK Sync selection from HTML to CodeMirror */
document.addEventListener('selectionchange', () => {
  if (cm.hasFocus() || dumbyContainer.onmousemove) {
    return
  }

  const selection = document.getSelection()
  if (selection.type === 'Range') {
    const content = selection.getRangeAt(0).toString()
    const parentWithSourceLine = selection.anchorNode.parentElement.closest('.source-line')
    const lineStart = Number(parentWithSourceLine?.dataset?.sourceLine ?? NaN)
    const nextSourceLine = parentWithSourceLine?.nextSibling?.dataset?.sourceLine
    const lineEnd = Number(nextSourceLine) ?? cm.doc.size
    // TODO Also return when range contains anchor element
    if (content.includes('\n') || isNaN(lineStart)) {
      cm.setSelection(cm.getCursor())
      return
    }

    const texts = [content]
    let sibling = selection.anchorNode.previousSibling
    while (sibling) {
      texts.push(sibling.textContent)
      sibling = sibling.previousSibling
    }

    const anchor = { line: lineStart, ch: 0 }

    texts
      .filter(t => t && t !== '\n')
      .map(t => t.replace('\n', ''))
      .reverse()
      .forEach(text => {
        let index = cm.getLine(anchor.line)?.indexOf(text, anchor.ch)
        while (index === -1) {
          anchor.line += 1
          anchor.ch = 0
          if (anchor.line >= lineEnd) return

          index = cm.getLine(anchor.line).indexOf(text)
        }
        anchor.ch = index + text.length
      })

    const focus = { line: anchor.line, ch: anchor.ch - content.length }
    cm.setSelection(focus, anchor)
    cm.scrollIntoView(focus)
  }
})