cursorScrollMargin for v6

I’ve been looking for an option to scroll the editor a little past the end before the cursor gets to that position.

In CodeMirror 5 it was cursorScrollMargin, am I right in thinking that in V6 I should be using EditorView.scrollMargins ?

i.e.:

EditorState.create({
  extensions: [EditorView.scrollMargins.of(() => ({ bottom: 50 }))]
})

Also slightly related - this is a pixel value. I would like this to be a “line value”, so I can make an option which is “keep N lines visible past the end of the cursor”.
Is there any way to get the height of a line?

No, EditorView.scrollMargins is not what you need here. There is no way to configure this at the moment, except is you are scrolling something into view explicitly with EditorView.scrollIntoView.

Ah, thanks for that! :heart: So I have this right now:

EditorState.transactionExtender.of((tr) => {
  return {
    effects: EditorView.scrollIntoView(tr.newSelection.main, {
      y: "end",
      yMargin: 25,
    }),
  };
})

And that seems to be much better and closer to CM5’s cursorScrollMargin.
(Obviously I need to configure it better to not just go to the "end", but I will do that later).

Is there any way to know how many pixels a line is? (I imagine CM knows this internally to configure its scrolling, etc)

Okay, I managed to answer my question:

EditorState.transactionExtender.of((tr) => {
  // NOTE: change this to configure how many lines should be visible before/after the cursor
  const nLines = 2;

  const { doc } = view.state;

  // get visible lines
  const rect = view.dom.getBoundingClientRect();
  const firstLineBlock = doc.lineAt(
    view.lineBlockAtHeight(rect.top - view.documentTop).from,
  );
  const lastLineBlock = doc.lineAt(
    view.lineBlockAtHeight(rect.bottom - view.documentTop).to,
  );

  // get line main selection is on
  const { main } = tr.newSelection;
  const mainLineBlock = view.lineBlockAt(main.head);
  const yMargin = mainLineBlock.height * nLines + mainLineBlock.height / 2;

  // if main selecting is scrolling up...
  if (mainLineBlock.to <= firstLineBlock.from + nLines) {
    return {
      effects: EditorView.scrollIntoView(main, { y: "start", yMargin }),
    };
  }

  // if main selection is scrolling down...
  if (mainLineBlock.to >= lastLineBlock.to - nLines) {
    return {
      effects: EditorView.scrollIntoView(main, { y: "end", yMargin }),
    };
  }

  // do nothing, scrolling in the middle of the visible range
  return null;
})

I think this should work okay? Seems to work from my testing.

I don’t really like that I have to pass in the EditorView to this extension, but I don’t think there’s any way around that.

I’ve iterated on this and fixed heaps of bugs with my previous implementation, and I’m leaving it here in case anyone wants to implement this kind of thing themselves:


/**
 * Number of lines above/below the cursor to keep visible in the viewport.
 * Defaults to 1.
 */
export const cursorLineMarginFacet = Facet.define<number, number>({
  combine: (input) => input[0] ?? 1,
});

const annotation = "cursorLineMargin";
const lineAtPos = (s: EditorState, pos: number) => s.doc.lineAt(pos).number;
// seems to the best approximation of CM5's `cursorScrollMargin`
// https://discuss.codemirror.net/t/cursorscrollmargin-for-v6/7448
export const cursorLineMargin: Extension = EditorView.updateListener.of(
  ({ transactions, state, selectionSet, startState, view }) => {
    // make sure we don't trigger an infinite loop and ignore our own changes
    if (transactions.some((tr) => tr.isUserEvent(annotation))) return;

    const s = state;
    const { main } = s.selection;
    const cursorLineMargin = s.facet(cursorLineMarginFacet);

    // editor rect
    const rect = view.dom.getBoundingClientRect();
    // top and bottom pixel positions of the visible region of the editor
    const viewportTop = rect.top - view.documentTop;
    const viewportBottom = rect.bottom - view.documentTop;

    // line with main selection
    const mainLine = lineAtPos(s, main.head);
    // top most visible line
    const visTopLine = lineAtPos(s, view.lineBlockAtHeight(viewportTop).from);

    // bottom most visible line
    // NOTE: calculating this is slightly more complex - if the editor is
    // larger than all the lines in it, and the `scrollPastEnd()` extension is
    // enabled, then we need to calculate where the bottom most visible line
    // should be if it extended all the way to the bottom of the editor
    const botLineBlk = view.lineBlockAtHeight(viewportBottom);
    const visBotLineDoc = lineAtPos(s, Math.min(botLineBlk.to, s.doc.length));
    const visBotLine =
      botLineBlk.bottom >= viewportBottom
        ? visBotLineDoc
        : visBotLineDoc +
          Math.floor(
            (viewportBottom - botLineBlk.bottom) / view.defaultLineHeight,
          );

    const needsScrollTop = mainLine <= visTopLine + cursorLineMargin;
    const needsScrollBot = mainLine >= visBotLine - cursorLineMargin;

    // the scroll margins are overlapping
    if (needsScrollTop && needsScrollBot) {
      console.warn("is cursorScrollMargin too large for the editor size?");
      return;
    }

    // no need to scroll, we're in between scroll regions
    if (!needsScrollTop && !needsScrollBot) {
      return;
    }

    // if we're within the margin, but moving the other way, then don't scroll
    if (selectionSet) {
      const { head } = startState.selection.main;
      const oldMainLine = lineAtPos(startState, head);
      if (needsScrollTop && oldMainLine < mainLine) return;
      if (needsScrollBot && oldMainLine > mainLine) return;
    }

    view.dispatch({
      annotations: Transaction.userEvent.of(annotation),
      effects: EditorView.scrollIntoView(s.selection.main.head, {
        y: needsScrollTop ? "start" : "end",
        yMargin: view.defaultLineHeight * cursorLineMargin,
      }),
    });
  },
);