OurBigBook logoOurBigBook Docs OurBigBook logoOurBigBook.comSite Source code
editor.js
class OurbigbookEditor {
  constructor(root_elem, initial_content, monaco, ourbigbook, ourbigbook_runtime, options) {
    this.ourbigbook = ourbigbook
    this.ourbigbook_runtime = ourbigbook_runtime
    this.modified = false
    this.monaco = monaco
    this.decorations = []
    if (options === undefined) {
      options = {}
    }
    if (!('convertOptions' in options)) {
      options.convertOptions = {}
    }
    if (!('body_only' in options)) {
      options.convertOptions.body_only = true
    }
    if (!('production' in options)) {
      options.production = true
    }
    if (!('modifyEditorInput' in options)) {
      options.modifyEditorInput = (titleSource, bodySource) => ({ offset: 0, new: bodySource })
    }
    this.modifyEditorInput = options.modifyEditorInput
    if (!('onDidChangeModelContentCallback' in options)) {
      options.onDidChangeModelContentCallback = (editor, event) => {}
    }
    if (!('postBuildCallback' in options)) {
      options.postBuildCallback = (extra_returns) => {}
    }
    if (!('scrollPreviewToSourceLineCallback' in options)) {
      options.scrollPreviewToSourceLineCallback = (opts) => {}
    }
    if (!('titleSource' in options)) {
      options.titleSource = undefined
    }
    this.options = options
    this.handleSubmit = this.options.handleSubmit
    this.titleSource = options.titleSource

    // Create input and output elems.
    const input_elem = document.createElement('div');
    input_elem.classList.add('input');
    const output_elem = document.createElement('div');
    this.output_elem = output_elem
    output_elem.classList.add('output');
    output_elem.classList.add('ourbigbook');
    const errors_elem = document.createElement('div');
    this.errors_elem = errors_elem
    errors_elem.classList.add('errors');
    errors_elem.classList.add('ourbigbook-body');
    root_elem.innerHTML = '';
    const panes = document.createElement('div')
    panes.className = 'editor-panes'
    panes.appendChild(input_elem);
    panes.appendChild(output_elem);
    panes.appendChild(errors_elem);
    root_elem.appendChild(panes)

    monaco.languages.register({ id: 'ourbigbook' });
    // TODO replace with our own tokenizer output:
    // https://github.com/ourbigbook/ourbigbook/issues/106
    monaco.languages.setMonarchTokensProvider('ourbigbook', {
      macroName: /[a-zA-Z0-9_]+/,
      tokenizer: {
        root: [
          [/\\@macroName/, 'macro'],
          [/\\./, 'escape'],

          // Positional arguments.
          [/\[\[\[/, 'literalStart', 'argumentDelimLiteral2'],
          [/\[\[/, 'literalStart', 'argumentDelimLiteral'],
          [/[[\]}]/, 'argumentDelim'],

          // Named arguments.
          [/{{/, 'argumentDelim', 'argumentNameLiteral'],
          [/{/, 'argumentDelim', 'argumentName'],

          [/\$\$\$/, 'literalStart', 'shorthandMath3'],
          [/\$\$/, 'literalStart', 'shorthandMath2'],
          [/\$/, 'literalStart', 'shorthandMath'],

          [/````/, 'literalStart', 'shorthandCode4'],
          [/```/, 'literalStart', 'shorthandCode3'],
          [/``/, 'literalStart', 'shorthandCode2'],
          [/`/, 'literalStart', 'shorthandCode'],

          [/^=+ .*/, 'shorthandHeader'],

          // Shorthand list.
          [/^(  )*\*( |$)/, 'argumentDelim'],
          // Shorthand table.
          [/^(  )*\|\|( |$)/, 'argumentDelim'],
          [/^(  )*\|( |$)/, 'argumentDelim'],
        ],
        argumentDelimLiteral: [
          [/\]\]/, 'literalStart', '@pop'],
          [/./, 'literalInside'],
        ],
        argumentDelimLiteral2: [
          [/\]\]\]/, 'literalStart', '@pop'],
          [/./, 'literalInside'],
        ],
        argumentName: [
          [/@macroName/, 'argumentName'],
          [/=/, 'argumentDelim', '@pop'],
          [/}/, 'argumentDelim', '@pop'],
        ],
        // TODO find a way to make content literalInside.
        argumentNameLiteral: [
          [/@macroName/, 'argumentName'],
          [/=/, 'argumentDelim', '@pop'],
          [/}}/, 'argumentDelim', '@pop'],
        ],
        shorthandCode: [
          [/`/, 'literalStart', '@pop'],
          [/./, 'literalInside'],
        ],
        shorthandCode2: [
          [/``/, 'literalStart', '@pop'],
          [/./, 'literalInside'],
        ],
        shorthandCode3: [
          [/```/, 'literalStart', '@pop'],
          [/./, 'literalInside'],
        ],
        shorthandCode4: [
          [/````/, 'literalStart', '@pop'],
          [/./, 'literalInside'],
        ],
        shorthandMath: [
          [/\$/, 'literalStart', '@pop'],
          [/./, 'literalInside'],
        ],
        shorthandMath2: [
          [/\$\$/, 'literalStart', '@pop'],
          [/./, 'literalInside'],
        ],
        shorthandMath3: [
          [/\$\$\$/, 'literalStart', '@pop'],
          [/./, 'literalInside'],
        ],
      }
    });
    monaco.editor.defineTheme('vs-dark-ourbigbook', {
      base: 'vs-dark',
      inherit: true,
      rules: [
        { token: 'argumentDelim', foreground: 'FFFFFF', fontStyle: 'bold' },
        { token: 'argumentName', foreground: 'FFAAFF', fontStyle: 'bold'},
        { token: 'shorthandHeader', foreground: 'FFFF00', fontStyle: 'bold' },
        { token: 'literalStart', foreground: 'FFFF00', fontStyle: 'bold' },
        { token: 'literalInside', foreground: 'FFFF88' },
        { token: 'macro', foreground: 'FF8800', fontStyle: 'bold' },
      ],
      // This option became mandatory after some update, even if empty, otherwise:
      // Cannot read properties of undefined (reading 'editor.foreground')
      colors: {},
    });
    const editor = monaco.editor.create(
      input_elem,
      {
        // https://stackoverflow.com/questions/47017753/monaco-editor-dynamically-resizable
        automaticLayout: true,
        folding: false,
        language: 'ourbigbook',
        minimap: {enabled: false},
        scrollBeyondLastLine: false,
        theme: 'vs-dark-ourbigbook',
        wordBasedSuggestions: false,
        wordWrap: 'on',
        value: initial_content,
      }
    );
    this.editor = editor
    // Monaco has no public suggestion-width option. Change its default through the
    // widget's layout info so viewport clamping and manual resizing still work.
    const suggestWidget = editor.getContribution?.('editor.contrib.suggestController')?.widget?.value
    if (suggestWidget?.getLayoutInfo) {
      const getLayoutInfo = suggestWidget.getLayoutInfo.bind(suggestWidget)
      suggestWidget.getLayoutInfo = () => {
        const info = getLayoutInfo()
        return { ...info, defaultSize: info.defaultSize.with(640) }
      }
    }
    this.completionProvider = monaco.languages.registerCompletionItemProvider('ourbigbook', {
      triggerCharacters: ['<', '=', '[', '@', '/'],
      provideCompletionItems: async (model, position, context, token) => {
        if (model !== editor.getModel()) return
        const line = model.getLineContent(position.lineNumber)
        const completion = ourbigbook.getIdCompletionContext(line.slice(0, position.column - 1))
        if (!completion) return
        // Monaco can keep this request alive while the user types, then request
        // updated incomplete results. Dropping it on a version change loses that
        // completion session, especially when typing during the debounce below.
        const stale = () => this.disposed || token.isCancellationRequested || model.isDisposed()
        const items = await this.getIdSuggestions(completion, position, stale)
        if (!items) return
        const closeIndex = line.indexOf(completion.close, position.column - 1)
        const range = new monaco.Range(position.lineNumber, completion.start + 1,
          position.lineNumber, closeIndex < 0 ? position.column : closeIndex + 2)
        return {
          incomplete: true,
          suggestions: items.map((item, i) => ({
            label: item.label,
            kind: monaco.languages.CompletionItemKind.Reference,
            insertText: item.reference + completion.close,
            filterText: completion.raw + ' ' + item.id,
            sortText: String(i).padStart(3, '0'),
            range,
          })),
        }
      },
    })
    this.toolbar_elem = createEditorToolbar(this, root_elem.ownerDocument)
    root_elem.insertBefore(this.toolbar_elem, panes)
    if (options.initialLine) {
      // https://stackoverflow.com/questions/45123386/scroll-to-line-in-monaco-editor
      editor.revealLineInCenter(options.initialLine)
    }
    editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => {
      if (this.handleSubmit) this.handleSubmit()
    })
    editor.onDidChangeModelContent(async (e) => {
      if (this.disposed) return
      options.onDidChangeModelContentCallback(editor, e)
      this.modified = true
      await this.convertInput()
    });
    editor.onDidScrollChange(e => {
      if (this.disposed) return
      const range = editor.getVisibleRanges()[0];
      if (!range) return
      const lineNumber = range.startLineNumber
      // So that the title bar will show on dynamic website
      // when user scrolls to line 1.
      const block = lineNumber === 1 ? 'center' : 'start'
      this.scrollPreviewToSourceLine(lineNumber, block);
    });
    editor.onDidChangeCursorPosition(e => {
      this.scrollPreviewToSourceLine(e.position.lineNumber, 'center');
    });
    this.convertInput();
    this.ourbigbook_runtime(this.output_elem)

    // https://github.com/cirosantilli/cirosantilli.github.io/issues/200
    // https://stackoverflow.com/questions/7317273/warn-user-before-leaving-web-page-with-unsaved-changes
    this.beforeunload = (e) => {
      if (this.modified) {
        e.preventDefault()
        return e.returnValue = this.modified;
      }
    }
    window.addEventListener('beforeunload', this.beforeunload)
  }

  // Shared by inline completion and the cross-reference search dialog.
  async getIdSuggestions(completion, position, stale = () => this.disposed) {
    const options = this.options
    const prefix = options.idCompletionPrefix || options.convertOptions.ref_prefix
    const localId = id => id === prefix ? '/' : prefix && id.startsWith(`${prefix}/`) ? id.slice(prefix.length + 1) : id
    const query = completion.query.replace(/^\//, '')
    const explicitUser = query.startsWith('@')
    const matches = id => (explicitUser || !prefix || id === prefix || id.startsWith(`${prefix}/`)) &&
      (explicitUser ? id : localId(id)).includes(query)
    let ids = Object.keys(this.completionIds || {}).filter(matches)
    const titles = { ...this.completionTitles }
    if (options.getIdCompletions) {
      // Incomplete suggestions are requested again as the user types. Avoid a request per keystroke.
      await new Promise(resolve => setTimeout(resolve, 150))
      if (stale()) return
      try {
        for (const { id, title } of await options.getIdCompletions(query)) {
          ids.push(id)
          // Prefer the title from the current draft over a saved version.
          if (!(id in titles)) titles[id] = title
        }
      } catch (error) {
        // Keep local suggestions usable when the server is unavailable.
      }
    }
    if (stale()) return
    ids = [...new Set(ids)].filter(matches)
    const rank = id => (explicitUser ? id : localId(id)).startsWith(query) ? 0 : 1
    ids.sort((a, b) => rank(a) - rank(b) || a.length - b.length || a.localeCompare(b))
    const referencePrefix = options.convertOptions.ref_prefix
    const sourceLine = position.lineNumber + (this.modifyEditorInputRet?.offset || 0)
    let header
    const findHeader = node => {
      for (const child of node?.children || []) {
        if (child.ast.source_location.line <= sourceLine) {
          if (!header || child.ast.source_location.line >= header.source_location.line) header = child.ast
          findHeader(child)
        }
      }
    }
    findHeader(this.lastHeaderTree)
    const scope = header?.calculate_scope()
    const absolute = completion.raw.startsWith('/') || (scope && scope !== referencePrefix)
    const referenceId = id => {
      if (referencePrefix && id.startsWith(`${referencePrefix}/`)) id = id.slice(referencePrefix.length + 1)
      return (absolute && !id.startsWith('@') ? '/' : '') + id
    }
    return ids.slice(0, 100).map(id => ({
      id,
      label: (explicitUser ? id : localId(id)) + (titles[id] ? ` | ${titles[id]}` : ''),
      reference: referenceId(id),
    }))
  }

  async convertInput() {
    if (this.disposed) return
    let extra_returns = {};
    let ok = true
    try {
      this.modifyEditorInputRet = this.modifyEditorInput(this.titleSource, this.getValue())
      const input = this.modifyEditorInputRet.new

      // Calculate possibly new input path based on conversion. This considers e.g.
      // disambiguate= and id= \H arguments that may have changed.
      let input_path
      const convertOptions = this.options.convertOptions
      const inputPathOrig = convertOptions.input_path
      if (inputPathOrig) {
        const parts = inputPathOrig.split(this.ourbigbook.Macro.HEADER_SCOPE_SEPARATOR)
        if (parts.length === 2 && parts[parts.length - 1] === this.ourbigbook.INDEX_BASENAME) {
          input_path = inputPathOrig
        } else {
          const getInputPathConvertOptions = Object.assign({}, convertOptions, {
            h1Only: true,
            splitHeaders: false,
            render: false,
          })
          // Keep only directory of input_path, ignore the basename.
          // The basename can be modified in the editor, but the directory not yet.
          delete getInputPathConvertOptions.input_path
          await this.ourbigbook.convert(
            input,
            getInputPathConvertOptions,
            extra_returns
          )
          if (this.disposed) return
          input_path = this.ourbigbook.idToScope(inputPathOrig)
          const newId = extra_returns.context.header_tree.children[0].ast.id
          let newBasename
          if (newId) {
            newBasename = newId
          } else {
            newBasename = this.ourbigbook.INDEX_BASENAME_NOEXT
          }
          input_path += this.ourbigbook.Macro.HEADER_SCOPE_SEPARATOR + newBasename + '.' + this.ourbigbook.OURBIGBOOK_EXT
        }
      }

      const convertOptionsCopy = Object.assign({}, convertOptions)
      convertOptionsCopy.input_path = input_path
      // Keep the exact input and calculated path used by the latest successful
      // preview conversion. The web article creator uses these to run the same
      // split_headers conversion as the CLI uploader when it is submitted.
      this.lastInput = input
      this.lastInputPath = input_path
      const output = await this.ourbigbook.convert(
        input,
        convertOptionsCopy,
        extra_returns
      )
      if (this.disposed) return
      this.output_elem.innerHTML = output
    } catch(e) {
      // TODO clearly notify user on UI that they found a Ourbigbook crash bug for the current input.
      console.error(e);
      ok = false
      if (!this.options.production) {
        // This shows proper stack traces in the console unlike what is shown on browser for some reason.
        //throw e
      }
    }
    if (ok) {
      this.completionIds = extra_returns.ids
      const completionContext = this.ourbigbook.convertInitContext({ output_format: this.ourbigbook.OUTPUT_FORMAT_ID })
      this.completionTitles = Object.fromEntries(Object.entries(this.completionIds).map(([id, ast]) =>
        [id, this.ourbigbook.getIdCompletionTitle(ast, completionContext)]))
      this.lastHeaderTree = extra_returns.context.header_tree
      // Rebind to newly generated elements.
      this.ourbigbook_runtime(this.output_elem);
      this.line_to_id = extra_returns.context.line_to_id;

      // Error handling.
      this.errors_elem.innerHTML = ''
      if (extra_returns.errors.length) {
        this.errors_elem.classList.add('has-error')
      } else {
        this.errors_elem.classList.remove('has-error')
      }
      if (extra_returns.errors.length) {
        const title = document.createElement('p');
        title.classList.add('title');
        title.innerHTML = '\u2718 Errors';
        this.errors_elem.appendChild(title)
      }
      for (const e of extra_returns.errors) {
        const error_elem = document.createElement('div');
        error_elem.classList.add('error')
        const a = document.createElement('a');
        a.classList.add('loc')
        const line = e.source_location.line - this.modifyEditorInputRet.offset
        a.innerHTML = `Line ${line}`
        a.addEventListener('click', (e) => { this.editor.revealLineNearTop(line) })
        error_elem.appendChild(a)
        error_elem.appendChild(document.createTextNode(`: ${e.message}`))
        this.errors_elem.appendChild(error_elem)
      }
      this.decorations = this.editor.deltaDecorations(
        this.decorations,
        extra_returns.errors.map(e => {
          const line = e.source_location.line - this.modifyEditorInputRet.offset
          return {
            range: new this.monaco.Range(line, 1, line, 1),
            options: {
              isWholeLine: true,
              linesDecorationsClassName: 'errorDecoration'
            }
          }
        })
      );

      await this.options.postBuildCallback(extra_returns, this)
    }
  }

  dispose() {
    if (this.disposed) return
    this.disposed = true
    this.closeXrefDialog?.()
    this.completionProvider.dispose()
    this.setToolbarDisabled(true)
    window.removeEventListener('beforeunload', this.beforeunload);
    this.editor.dispose()
  }

  setToolbarDisabled(disabled) {
    for (const control of this.toolbar_elem.querySelectorAll('button, select')) {
      control.disabled = disabled
    }
  }

  getValue() {
    // TODO use model.setEOL(monaco.editor.EndOfLineSequence.LF) instead of the \r\n.
    // Haven't done yet because lazy to boot into Windows:
    // https://stackoverflow.com/questions/56525822/how-to-set-eol-to-lf-for-windows-so-that-api-gets-value-with-n-not-r-n/74624712#74624712
    // https://github.com/microsoft/monaco-editor/issues/3440
    let ret = this.editor.getValue().replaceAll('\r\n', '\n').replace(/^(\n+)?$/, '')
    if (ret.length) {
      ret = ret.replace(/(\n+)?$/, '\n')
    }
    return ret
  }

  scrollPreviewToSourceLine(line_number, block) {
    if (this.disposed || !this.modifyEditorInputRet) return
    const line_number_orig = line_number
    line_number += this.modifyEditorInputRet.offset
    if (block === undefined) {
      block = 'center';
    }
    if (this.line_to_id) {
      // Can fail in case of conversion errors.
      const id = this.line_to_id(line_number);
      if (
        // Possible on empty document.
        id !== ''
      ) {
        // TODO this would be awesome to make the element being targeted red,
        // but it loses editor focus  on new paragraphs (e.g. double newline,
        // making it unusable.
        // window.location.hash = id;
        const elem = document.getElementById(id)
        if (elem) {
          if (line_number_orig === 1) {
            // To show the h1 toplevel.
            this.output_elem.scrollTop = 0
          } else {
            // https://stackoverflow.com/questions/45408920/plain-javascript-scrollintoview-inside-div
            // https://stackoverflow.com/questions/5389527/how-to-get-offset-relative-to-a-specific-parent
            // https://stackoverflow.com/questions/37137450/scroll-all-nested-scrollbars-to-bring-an-html-element-into-view
            function scrollParentToChild(parent, child) {
              const parentRect = parent.getBoundingClientRect();
              const childRect = child.getBoundingClientRect();
              const scrollTop = childRect.top - parentRect.top;
              parent.scrollTop += scrollTop;
            }
            scrollParentToChild(this.output_elem, elem);
          }
        }
        // A cursor event can arrive after the source changed but before its
        // asynchronous preview conversion completed. In that normal case the
        // new ID is not in the old preview DOM yet, so there is nothing to do.
      };
    }
    this.options.scrollPreviewToSourceLineCallback({ ourbigbook_editor: this, line_number, line_number_orig })
  }

  async setTitleSource(titleSource) {
    this.titleSource = titleSource
    await this.convertInput()
  }

  openXrefDialog() {
    if (this.disposed || this.closeXrefDialog) return
    const { editor } = this
    const model = editor.getModel()
    const selection = editor.getSelection()
    if (!model || !selection) return
    const version = model.getVersionId()
    const doc = this.toolbar_elem.ownerDocument
    const dialog = doc.createElement('dialog')
    dialog.className = 'editor-xref-dialog'
    dialog.setAttribute('aria-label', 'Insert cross-reference')
    const form = doc.createElement('form')
    const title = doc.createElement('h2')
    title.textContent = 'Insert cross-reference'
    const label = doc.createElement('label')
    label.textContent = 'Search IDs'
    const input = doc.createElement('input')
    input.type = 'text'
    input.autocomplete = 'off'
    input.placeholder = 'Search by ID, or @username/id'
    input.value = model.getValueInRange(selection).trim().replace(/^<([^<>]+)>$/, '$1').replace(/\s+/g, ' ')
    label.appendChild(input)
    const status = doc.createElement('p')
    status.setAttribute('role', 'status')
    const results = doc.createElement('select')
    results.size = 8
    results.setAttribute('aria-label', 'Matching cross-references')
    const actions = doc.createElement('div')
    actions.className = 'editor-xref-actions'
    const cancel = doc.createElement('button')
    cancel.type = 'button'
    cancel.textContent = 'Cancel'
    const insert = doc.createElement('button')
    insert.type = 'submit'
    insert.textContent = 'Insert'
    insert.disabled = true
    actions.append(cancel, insert)
    form.append(title, label, status, results, actions)
    dialog.appendChild(form)
    this.toolbar_elem.parentNode.appendChild(dialog)
    let closed = false
    let request = 0
    let items = []
    const close = () => {
      if (closed) return
      closed = true
      if (dialog.open) dialog.close()
      dialog.remove()
      this.closeXrefDialog = undefined
      if (!this.disposed) editor.focus()
    }
    this.closeXrefDialog = close
    dialog.addEventListener('cancel', event => { event.preventDefault(); close() })
    dialog.addEventListener('close', close)
    cancel.addEventListener('click', close)
    const search = async () => {
      const currentRequest = ++request
      const stale = () => closed || this.disposed || currentRequest !== request
      items = []
      results.innerHTML = ''
      results.disabled = true
      insert.disabled = true
      status.textContent = 'Searching…'
      const completion = this.ourbigbook.getIdCompletionContext('<' + input.value)
      const matches = completion ? await this.getIdSuggestions(completion, selection.getStartPosition(), stale) : []
      if (stale()) return
      items = matches || []
      for (const [i, item] of items.entries()) {
        const option = doc.createElement('option')
        option.value = String(i)
        option.textContent = item.label
        results.appendChild(option)
      }
      results.selectedIndex = items.length ? 0 : -1
      results.disabled = insert.disabled = items.length === 0
      status.textContent = items.length ? `${items.length} results` : 'No matching IDs.'
    }
    input.addEventListener('input', search)
    input.addEventListener('keydown', event => {
      if (event.key === 'ArrowDown' && items.length) {
        event.preventDefault()
        results.focus()
      }
    })
    results.addEventListener('keydown', event => {
      if (event.key === 'Enter') { event.preventDefault(); form.requestSubmit() }
    })
    results.addEventListener('dblclick', () => form.requestSubmit())
    form.addEventListener('submit', event => {
      event.preventDefault()
      const item = items[results.selectedIndex]
      if (closed || this.disposed || insert.disabled || !item) return
      if (model !== editor.getModel() || model.getVersionId() !== version) {
        status.textContent = 'The document changed. Close this dialog and try again.'
        insert.disabled = true
        return
      }
      close()
      editor.setSelection(selection)
      applyEditorMarkup(this, 'xref', { xrefTarget: item.reference })
    })
    dialog.showModal()
    input.focus()
    search()
  }
}


// Source edits for the shared editor toolbar. Offsets refer to the original Monaco model.
// Keep this independent of the DOM so the generated markup can be tested with the converter.
function isWrapped(text, open) {
  if (!text.startsWith(open)) return false
  let depth = 1
  for (let i = open.length; i < text.length; i++) {
    if (text[i] === '\\') i++
    else if (text[i] === '[') depth++
    else if (text[i] === ']' && --depth === 0) return i === text.length - 1
  }
  return false
}

function getMarkupEdit(action, value, start, end, options={}) {
  const eol = value.includes('\r\n') ? '\r\n' : '\n'
  const selected = value.slice(start, end)
  const edit = (text, from=0, to=text.length) => ({
    start, end, text, selectionStart: start + from, selectionEnd: start + to,
  })
  const wrap = (before, content, after) => edit(before + content + after, before.length, before.length + content.length)

  if (action === 'bold' || action === 'italic') {
    const open = action === 'bold' ? '\\b[' : '\\i['
    if (start !== end && value.slice(start - open.length, start) === open && value[end] === ']') {
      start -= open.length
      end++
      return edit(selected)
    }
    if (!selected) return wrap(open, action === 'bold' ? 'bold text' : 'italic text', ']')
    // Format each line separately: inline macros cannot contain paragraphs or list items.
    const lines = selected.split(eol).map(line => line.match(/^([ \t]*(?:(?:\*|>|\|\|?|={1,6}) )?)(.*?)([ \t]*)$/))
    const remove = lines.filter(parts => parts[2]).every(parts => isWrapped(parts[2], open))
    const text = lines.map(([, prefix, content, suffix]) => prefix + (content
      ? remove ? content.slice(open.length, -1) : open + content + ']'
      : '') + suffix).join(eol)
    if (lines.length === 1 && !remove && lines[0][2]) {
      const from = lines[0][1].length + open.length
      return edit(text, from, from + lines[0][2].length)
    }
    return edit(text)
  }

  if (action === 'link') {
    if (/^https?:\/\/\S+$/i.test(selected)) {
      const url = selected.replace(/[\\[\]{}]/g, '\\$&')
      return wrap(`${url}[`, 'link text', ']')
    }
    const url = 'http://example.com'
    return edit(`${url}[${selected || 'link text'}]`, 0, url.length)
  }

  if (action === 'xref') {
    const target = options.xrefTarget.replace(/[\\[\]{}<>]/g, '\\$&')
    const text = selected && !/^<[^<>]+>$/.test(selected)
      ? `\\x[${target}][${selected}]` : `<${target}>`
    return edit(text, text.length, text.length)
  }

  if ((action === 'code' || action === 'math') && !selected.includes('\n')) {
    const math = action === 'math'
    const content = selected || (math ? 'x^2' : 'code')
    const delimiter = math ? '$' : '`'
    if (!content.includes(delimiter)) return wrap(delimiter, content, delimiter)
    // Literal arguments preserve embedded delimiters, brackets and backslashes.
    const brackets = Math.max(2, ...Array.from(content.matchAll(/[\[\]]+/g), match => match[0].length + 1))
    return wrap((math ? '\\m' : '\\c') + '['.repeat(brackets) + eol, content, eol + ']'.repeat(brackets))
  }

  // Most block actions affect whole touched lines. A selection ending at column 1
  // belongs to the preceding line, as with Monaco's built-in indentation commands.
  const originalStart = start
  const originalEnd = end
  if (action !== 'image' && (action !== 'table' || start !== end)) {
    start = start === 0 ? 0 : value.lastIndexOf('\n', start - 1) + 1
    if (end > originalStart && value[end - 1] === '\n') end -= eol.length
    const nextLine = value.indexOf('\n', end)
    end = nextLine < 0 ? value.length : nextLine - (value[nextLine - 1] === '\r' ? 1 : 0)
  }
  const content = value.slice(start, end)
  const lines = content.split(eol)
  const block = (text, from=0, to=text.length) => {
    const before = value.slice(0, start)
    const after = value.slice(end)
    const prefix = !before || before.endsWith(eol + eol) ? '' : before.endsWith(eol) ? eol : eol + eol
    const suffix = !after || after.startsWith(eol + eol) ? '' : after.startsWith(eol) ? eol : eol + eol
    return edit(prefix + text + suffix, prefix.length + from, prefix.length + to)
  }

  if (action === 'image') {
    if (options.imageSource !== undefined) {
      const source = options.imageSource.replace(/[\\[\]{}]/g, '\\$&')
      const text = `\\Image[${source}]`
      return block(text, text.length)
    }
    const isUrl = /^https?:\/\/\S+$/i.test(selected)
    const url = isUrl ? selected.replace(/[\\[\]{}]/g, '\\$&') : 'http://example.com/image.jpg'
    const title = isUrl ? 'Image title' : selected || 'Image title'
    const open = '\\Image['
    const before = `${open}${url}]${eol}{title=`
    const from = isUrl ? before.length : open.length
    return block(before + title + '}', from, from + (isUrl ? title.length : url.length))
  }

  if (action === 'indent' || action === 'outdent') {
    const text = lines.map(line => action === 'indent' ? '  ' + line : line.replace(/^(?: {1,2}|\t)/, '')).join(eol)
    if (originalStart === originalEnd) {
      const removed = lines[0].length - text.length
      const cursor = Math.max(0, originalStart - start - removed)
      return edit(text, cursor, cursor)
    }
    return edit(text)
  }

  if (action === 'bullet-list' || action === 'numbered-list') {
    const hasContent = content.trim().length > 0
    const remove = action === 'bullet-list' && hasContent && lines.filter(line => line.trim()).every(line => /^ *\* /.test(line))
    const items = hasContent ? lines.map(line => {
      if (!line.trim()) return line
      if (remove) return line.replace(/^( *)\* /, '$1')
      return /^ *\* /.test(line) ? line : line.replace(/^( *)(.*)$/, '$1* $2')
    }).join(eol) : '* List item'
    if (action === 'numbered-list') {
      const before = '\\Ol[' + eol
      return block(before + items + eol + ']', before.length + (hasContent ? 0 : 2), before.length + items.length)
    }
    return block(items, hasContent ? 0 : 2)
  }

  if (action === 'quote') {
    const body = content || 'Quoted text'
    const text = body.split(eol).map((line, i) => i === 0 ? '> ' + line : line ? '  ' + line : '').join(eol)
    return block(text, 2)
  }

  if (['code', 'code-block', 'math', 'math-block'].includes(action)) {
    const math = action === 'math' || action === 'math-block'
    const body = content || (math ? '\\frac{a}{b}' : 'Code goes here')
    const fence = (math ? '$' : '`').repeat(Math.max(2, ...Array.from(body.matchAll(math ? /\$+/g : /`+/g), match => match[0].length + 1)))
    return block(fence + eol + body + eol + fence, fence.length + eol.length, fence.length + eol.length + body.length)
  }

  if (action === 'table') {
    const rows = content.trim() ? lines.filter(line => line.trim()).map(line => line.split('\t')) : [
      ['Heading 1', 'Heading 2'],
      ['Cell 1', 'Cell 2'],
    ]
    const columns = Math.max(...rows.map(row => row.length))
    const text = rows.map((row, i) => Array.from({ length: columns }, (_, j) =>
      `${i === 0 ? '||' : '|'} ${row[j] || ''}`
    ).join(eol)).join(eol + eol)
    return block(text, 3, 3 + rows[0][0].length)
  }

  if (/^heading-[1-6]$/.test(action)) {
    const prefix = '='.repeat(Number(action.slice(-1))) + ' '
    const text = content.trim() ? lines.filter(line => line.trim()).map(line =>
      prefix + line.trim().replace(/^={1,6} +/, '')
    ).join(eol + eol) : prefix + 'Heading'
    return block(text, prefix.length)
  }

  throw new Error(`Unknown editor markup action: ${action}`)
}

function applyEditorMarkup(ourbigbookEditor, action, options={}) {
  const { editor, monaco } = ourbigbookEditor
  const model = editor.getModel()
  const selection = editor.getSelection()
  if (!model || !selection) return
  const edit = getMarkupEdit(action, model.getValue(),
    model.getOffsetAt(selection.getStartPosition()), model.getOffsetAt(selection.getEndPosition()), options)
  const start = model.getPositionAt(edit.start)
  const end = model.getPositionAt(edit.end)
  editor.pushUndoStop()
  editor.executeEdits('ourbigbook-toolbar', [{
    range: new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column),
    text: edit.text,
  }], () => {
    const from = model.getPositionAt(edit.selectionStart)
    const to = model.getPositionAt(edit.selectionEnd)
    return [new monaco.Selection(from.lineNumber, from.column, to.lineNumber, to.column)]
  })
  editor.pushUndoStop()
  editor.focus()
  editor.revealRangeInCenterIfOutsideViewport(editor.getSelection())
}


function createEditorToolbar(ourbigbookEditor, doc=document) {
  const toolbar = doc.createElement('div')
  toolbar.className = 'editor-toolbar'
  toolbar.setAttribute('role', 'toolbar')
  toolbar.setAttribute('aria-label', 'Text formatting')
  const groups = [
    [
      ['bold', 'B', 'Bold β€” format selected text', 'b'],
      ['italic', 'I', 'Italic β€” format selected text', 'i'],
      ['link', 'πŸ”— Link', 'Link β€” use selected text or URL'],
      ['xref', 'β†— Cross-reference', 'Link to an internal ID'],
      ['code', '</> Inline code', 'Inline code β€” format selected text'],
      ['code-block', '{ } Code block', 'Code block β€” format selected lines'],
    ],
    [
      ['math', 'xΒ² Inline math', 'Inline math β€” format selected LaTeX'],
      ['math-block', 'βˆ‘ Block math', 'Block math β€” format selected LaTeX lines'],
    ],
    [
      ['bullet-list', 'β€’ List', 'Bullet list β€” toggle bullets on selected lines'],
      ['numbered-list', '1. List', 'Numbered list β€” turn selected lines into items'],
      ['quote', 'Quote', 'Quote β€” quote selected lines'],
      ['table', 'Table', 'Table β€” turn selected lines into rows and tabs into columns; first row is headings'],
      ['image', 'πŸ–Ό Image', 'Insert a block image'],
    ],
    [
      ['outdent', '←', 'Decrease indentation by two spaces'],
      ['indent', 'β†’', 'Increase indentation by two spaces'],
    ],
  ]
  const groupElement = () => {
    const group = doc.createElement('div')
    group.className = 'editor-toolbar-group'
    toolbar.appendChild(group)
    return group
  }
  for (const group of groups) {
    const element = groupElement()
    for (const [action, label, title, tag] of group) {
      const button = doc.createElement('button')
      button.type = 'button'
      button.title = title
      button.setAttribute('aria-label', title)
      button.setAttribute('data-action', action)
      const content = tag ? doc.createElement(tag) : button
      content.textContent = label
      if (tag) button.appendChild(content)
      button.addEventListener('mousedown', event => event.preventDefault())
      button.addEventListener('click', () => {
        if (!button.disabled) {
          if (action === 'image' && ourbigbookEditor.options.onImage) ourbigbookEditor.options.onImage(ourbigbookEditor)
          else if (action === 'xref') ourbigbookEditor.openXrefDialog()
          else applyEditorMarkup(ourbigbookEditor, action)
        }
      })
      element.appendChild(button)
    }
  }
  const levels = ourbigbookEditor.options.toolbarHeaderLevels || [1, 2, 3, 4, 5, 6]
  if (levels.length) {
    const select = doc.createElement('select')
    select.setAttribute('aria-label', 'Insert heading')
    select.title = 'Turn the current line or selected lines into headings'
    const placeholder = doc.createElement('option')
    placeholder.textContent = 'Heading'
    placeholder.value = ''
    placeholder.disabled = true
    placeholder.selected = true
    select.appendChild(placeholder)
    for (const level of levels) {
      const option = doc.createElement('option')
      option.value = `heading-${level}`
      option.textContent = `Heading ${level}`
      select.appendChild(option)
    }
    select.addEventListener('change', () => {
      if (!select.disabled && select.value) applyEditorMarkup(ourbigbookEditor, select.value)
      select.value = ''
    })
    groupElement().appendChild(select)
  }
  toolbar.addEventListener('keydown', event => {
    if (event.target.tagName === 'SELECT') return
    if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return
    const controls = Array.from(toolbar.querySelectorAll('button:enabled, select:enabled'))
    const index = controls.indexOf(event.target)
    if (index < 0) return
    event.preventDefault()
    const next = event.key === 'Home' ? 0 : event.key === 'End' ? controls.length - 1
      : (index + (event.key === 'ArrowRight' ? 1 : -1) + controls.length) % controls.length
    controls[next].focus()
  })
  return toolbar
}

if (typeof exports !== 'undefined') {
  exports.OurbigbookEditor = OurbigbookEditor;
  exports.getMarkupEdit = getMarkupEdit
  exports.applyEditorMarkup = applyEditorMarkup
}