Scroll to a specific node in the editor (middle of the parent editor container)

Hey folks!

I’m banging my head around this issue I have

I’m trying to scroll to a specific node in the editor for a given attribute name and value, and we need to scroll to the middle of the scrollable parent container

My current solution is to pass down the parent ref (we are working in React), find the node directly from the DOM, and then use getBoundingClientRect() to calculate the scroll position

Here is the code so far:

const scrollToPosition = debounce(
  (node: HTMLElement, scrollableParent?: HTMLElement | null) => {
    if (node && scrollableParent) {
      const nodeRect = node.getBoundingClientRect();
      const parentRect = scrollableParent.getBoundingClientRect();

      const scrollPosition =
        nodeRect.top -
        parentRect.top -
        scrollableParent.clientHeight / 2 +
        nodeRect.height / 2;

      scrollableParent.scrollTo({
        top: scrollPosition,
        behavior: "smooth",
      });
    }
  },
  0
);

export const ScrollAtPosition = Extension.create({
  name: "scrollAtPosition",
  addCommands() {
    return {
      scrollAtPositionFromAttributeValue:
        (
          attr: string,
          value: string | boolean,
          scrollableParent?: HTMLElement | null
        ) =>
        ({ state, tr, view }) => {
          const nodes = Array.from(view.dom.querySelectorAll(`[${attr}]`));

          const node = nodes.find(
            (node: Element) => node.getAttribute(attr) === value
          );

          if (node) {
            console.log("scroll to", attr, value);
            scrollToPosition(node as HTMLElement, scrollableParent);
          } else {
            console.log("couldn't find node to scroll to", attr, value);
          }
          return true;
        },
    };
  },
});

Unfortunately, it’s very unreliable and doesn’t scroll in the middle of the screen, it’s way off

I hypothesize that the results from getBoundingClientRect() on the DOM node are wrong, but I’m not sure

What’s the idiomatic Prosemirror approach to achieve this?

Important context = we have multiple editors in the same parent container, so we are not in a situation where the editor fills the entire parent container size

Have you taken a look at view.domAtPos()? We have a command in our app that’s similar to the one you’re working on:

/**
 * Scroll the window to a position in the editor
 *
 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView}
 *
 * @param {number} pos - the absolute position to scroll to
 * @param {object} [scrollArgs] - arguments to pass to scrollIntoView.
 * @param {string} [scrollArgs.block] - defaults to 'center'
 * @param {string} [scrollArgs.behavior] - defaults to 'smooth'
 * @param {string} [scrollArgs.inline] - defaults to 'nearest'
 *
 * @returns {function(): boolean}
 */
export default function scrollToPos(pos, scrollArgs) {
    return function(state, dispatch, view) {
        const {node: scrollEl} = view.domAtPos(pos);

        if (scrollEl?.nodeType === Node.TEXT_NODE) {
            scrollEl = scrollEl.parentElement;
        }

        if (scrollEl && dispatch) {
            scrollEl.scrollIntoView({
                block: scrollArgs?.block ?? 'center',
                behavior: scrollArgs?.behavior ?? 'smooth',
                inline: scrollArgs?.inline ?? 'nearest',
            });
        }

        return Boolean(scrollEl);
    };
}

Here’s working code for us. This is used for scrolling to a page header, but it should be easy to modify how it looks up the node pos and the element being scrolled. It also sets selection on the node:


export function scrollToHeadingNode({ view, headingSlug }: { view: EditorView; headingSlug: string }) {
  let nodePos: number | undefined;
  view.state.doc.descendants((node, pos) => {
    if (node.type.name === 'heading' && slugify(node.textContent) === headingSlug) {
      nodePos = pos;
      return false;
    }
  });
  if (typeof nodePos === 'number') {
    view.dispatch(
      view.state.tr
        .setSelection(NodeSelection.create(view.state.doc, nodePos))
        // disable floating menu or other tooltips from appearing
        .setMeta('skip-selection-tooltip', true)
    );
    // scroll to top of the heading node
    const domTopPosition = view.coordsAtPos(nodePos).top;
    document.querySelector('.document-print-container')?.scrollTo({ top: domTopPosition });
  }
}