Possible bug in commands.insertTab when using indentUnits

I’m using the insertTab and indentLess commands for block intenting, using keymap code:

    keymap.of([
      {
        key: `Tab`,
        preventDefault: true,
        run: insertTab,
      },
      {
        key: `Shift-Tab`,
        preventDefault: true,
        run: indentLess,
      },
    ])

However, in order to match the content it’s for, I need spaces instead of tabs, so I added indentUnits.of(" ") and EditorState.tabSize.of(4) statements, but pressing tab in the editor still writes a \t instead of the four spaces I told it to.

Looking at the insertTab command source code, it feels like there’s a bug here?

/// Insert a tab character at the cursor or, if something is selected,
/// use [`indentMore`](#commands.indentMore) to indent the entire
/// selection.
export const insertTab: StateCommand = ({state, dispatch}) => {
  if (state.selection.ranges.some(r => !r.empty)) return indentMore({state, dispatch})
  dispatch(state.update(state.replaceSelection("\t"), {scrollIntoView: true, userEvent: "input"}))
  return true
}

Unless I’m reading this wrong, this code ignores whatever was specified via indentUnits and instead of uses a hardcoded tab code?

(and if this is indeed a bug, I don’t know if that means that indentLess needs a fix, too)

insertTab, as the name implies, inserts a tab. Maybe you’re looking for indentMore instead.

1 Like

That would make sense, wouldn’t it…

Is there a way to tell indentMore to just insert the spaces at the cursor, when in the middle of a line rather than in leading whitespace? (when there is no selection)

No, that doesn’t exist as a pre-written command. It should be pretty easy to write, though.

1 Like

Fair enough. In case it’s useful for other folks who happen across this because of google searching or whatnot, I ended up with this:

  const INDENT_STRING = `  `;
  const TAB_NOTICE_TEXT =
    `Using tab for code indentation. To tab out of the editor, press escape first.`;

  let bypassTabs = false;

  extensions.push(
    indentUnit.of(INDENT_STRING),
    EditorState.tabSize.of(INDENT_STRING.length),
    keymap.of([
      {
        key: `Escape`,
        run: () => {
          bypassTabs = true;
          new Notice(
            `Escaping the editor: pressing tab will now focus on the next focusable element on the page.`
          );
        },
      },
      {
        key: `Tab`,
        preventDefault: true,
        run: (view) => {
          if (bypassTabs) return (bypassTabs = false);

          createOneTimeNotice(TAB_NOTICE_TEXT, 5000);

          // Multi line selection = indent
          const { doc, selection } = view.state;
          const { ranges } = selection;
          const aLine = doc.lineAt(ranges.at(0).from);
          const hLine = doc.lineAt(ranges.at(-1).to);
          const multiline = aLine.number !== hLine.number;
          if (multiline) return indentMore(view);

          // single line: do we indent, or insert/replace spaces?
          const pos = selection.main.head;
          const { from, to } = ranges[0];

          // scoped helper function for single line "add spaces somewhere":
          const indent = (from, to = from) => {
            view.dispatch({
              changes: { from, to, insert: INDENT_STRING },
              selection: { anchor: from + INDENT_STRING.length },
            });
            return true;
          };

          // text selection = replace with spaces
          if (from !== to) return indent(from, to);

          // Anything else = insert spaces
          return indent(pos);
        },
      },
      {
        key: `Shift-Tab`,
        preventDefault: true,
        run: (view) => {
          if (bypassTabs) return (bypassTabs = false);
          createOneTimeNotice(TAB_NOTICE_TEXT, 5000);
          return indentLess(view);
        },
      },
    ])

With the note that this has an “escape” for users who rely on tab to navigate pages. Pressing esc acts as a one-time escape so you can tab or shift-tab out. When you shift-tab or tab back in, you’ll be trapped again (until the next esc).

1 Like

I started from your solution, but I think the number of spaces should vary based on the column number each cursor is at. So I rewrote it to the following:

import { indentMore, indentLess } from '@codemirror/commands'
import { getIndentUnit } from '@codemirror/language'
import { EditorSelection } from '@codemirror/state'
/** @import { Command, KeyBinding } from '@codemirror/view' */

/** @type {Command} */
function indentMoreOrInsertTab(view) {
  const { doc, selection } = view.state
  const { ranges } = selection

  if (ranges.length == 0) {
    // cannot continue if having no selections
    return false
  }

  /** @type {Map<number, number>} */
  const colNoMap = new Map()
  const hasMultilineSelection = ranges.some(range => {
    const line = doc.lineAt(range.from)
    colNoMap.set(range.from, range.from - line.from)
    return (line.number !== doc.lineAt(range.to).number)
  })

  if (hasMultilineSelection) {
    return indentMore(view)
  }

  // else the mapped will contain info about all ranges

  const indentUnit = getIndentUnit(view.state)
  // TODO: now assuming it's true
  // const useSoftTab = true

  const tx = view.state.changeByRange(range => {
    const { from, to } = range
    const col = /** @type {number} */(colNoMap.get(from))
    const count = indentUnit - (col % indentUnit)
    const changes = { from: from, to, insert: ' '.repeat(count) }
    return {
      changes,
      range: EditorSelection.cursor(from + count),
    }
  })

  view.dispatch(tx)
  return true
}

/** @type {KeyBinding} */
export const myIndentWithTab = {
  key: 'Tab',
  run: indentMoreOrInsertTab,
  shift: indentLess,
}

Ooh, nice, thanks!