Changing the node type of a list

I’m trying to implement a command which can directly switch a deeply nested list structure to a different type, for example a bullet list to a numbered list. I tried different approaches:

Sink & Lift: First sink the items, then lift them into the new desired type. The problem with this approach is that i have to call dispatch twice (which means it’s not a single transaction and therefore probably semantically incorrect), and the type switch is applied only at the first level.

node.descendants + setNodeMarkup: I tried to step through all the descendants of a list item, and if the descendant is a list or item type, call tr.setNodeMarkup() for the given position to update the type. This works if the the item type we are switching to is compatible with the list type the structure currently has. But i have a special scenario (custom todo_list and todo_item types) which are not compatible with the standard list_item.

Recursively step down a node: I tried to recursively step down into a node hierarchy, but the problem is that the node.content property (which is a Fragment) is not iterable. Using descendants is not applicable, because i loose the hierarchy.

So, at this point i’m pretty lost. Any ideas how this should be implemented?

Just did basically the same thing. Iterate over the list node in the doc tr.doc.nodesBetween(pos, pos + node.nodeSize (or descendants works as well I suppose) creating new list children as you prefer (I just check whether the node is list_item and then push it to a children: PMNode[] list). After you’re done, replace the old node with tr.replaceWith(pos, pos + node.nodeSize, newNodes) (I create multiple nodes but you probably just have one).

And yeah, if you need to mess with the Fragment’s content (I didn’t this time) I’ve used the hack of just accessing it directly with frag.content. But you either have to ts-ignore that or override Fragment’s type. A bit annoying it’s not public, I agree.

What is wrong with using Fragment.forEach or childCount/child?

Oh yeah, there’s that. In my case I had to iterate over possibly multiple nodes. But well once you find a good tool that does most things it’s easier to keep just using that instead of switching based on if it’s local to one node or if it spans multiple.

You said that Fragment.content is “not iterable”, but it’s non entirely true. Fragment.content is readonly. It means you shouldn’t mutate it directly.

I recently worked on something somewhat similar and needed to traverse the tree as well.

I came up with these helpers. They iterate on content but create new Fragments.

import { Slice, Fragment, Node } from "prosemirror-model";

export const mapFragment = (
  fragment: Fragment,
  callback: (node: Node) => Node | Node[] | Fragment | null
): Fragment =>
  Fragment.fromArray(
    (fragment as any).content.map((node: Node) => {
      if (node.content.childCount > 0) {
        return node.type.create(
          node.attrs,
          mapFragment(node.content, callback)
        );
      }

      return callback(node);
    })
  );

export const mapSlice = (
  slice: Slice,
  callback: (node: Node) => Node | Node[] | Fragment | null
): Slice => {
  const fragment = mapFragment(slice.content, callback);
  return new Slice(fragment, slice.openStart, slice.openEnd);
};

You can then use it like that:

mapSlice(slice, callbackFunction);

You just need to remember that in callbackFunction whatever transformation you do, you need to create new Nodes even if you don’t want to change them. You can do that by using node.copy(node.content) and/or e.g.

node.type.create({ ...node.attrs, }, node.content, node.marks

(remember to pass marks if you want to preserve them)

I hope that helps you keep going and that I didn’t violate any Prosemirror fundamentals here :smiley:

Yes, thanks, i missed that api. I implemented the recursive solution using Fragment.childCount / Fragment.replaceChild.

function findCommonListNode(
  state: EditorState,
  listTypes: Array<NodeType>
): { node: Node; from: number; to: number } | null {
  const range = state.selection.$from.blockRange(state.selection.$to);

  if (!range) {
    return null;
  }

  const node = range.$from.node(-2);

  if (!node || !listTypes.find((item) => item === node.type)) {
    return null;
  }

  const from = range.$from.posAtIndex(0, -2);

  return { node, from, to: from + node.nodeSize - 1 };
}

function updateContent(
  content: Fragment,
  targetListType: NodeType,
  targetListItemType: NodeType,
  listTypes: Array<NodeType>,
  listItemTypes: Array<NodeType>
): Fragment {
  let newContent = content;

  for (var i = 0; i < content.childCount; i++) {
    newContent = newContent.replaceChild(
      i,
      updateNode(
        newContent.child(i),
        targetListType,
        targetListItemType,
        listTypes,
        listItemTypes
      )
    );
  }

  return newContent;
}

function getReplacementType(
  node: Node,
  target: NodeType,
  options: Array<NodeType>
): NodeType | null {
  return options.find((item) => item === node.type) ? target : null;
}

function updateNode(
  node: Node,
  targetListType: NodeType,
  targetListItemType: NodeType,
  listTypes: Array<NodeType>,
  listItemTypes: Array<NodeType>
): Node {
  const newContent = updateContent(
    node.content,
    targetListType,
    targetListItemType,
    listTypes,
    listItemTypes
  );

  const replacementType =
    getReplacementType(node, targetListType, listTypes) ||
    getReplacementType(node, targetListItemType, listItemTypes);

  if (replacementType) {
    return replacementType.create(node.attrs, newContent, node.marks);
  } else {
    return node.copy(newContent);
  }
}

export function buildWrapInListItem(
  schema: Schema,
  targetListType: NodeType,
  targetListItemType: NodeType,
  id: string,
  attrs?: { [key: string]: string | number | boolean }
): MenuItemSpec {
  const listTypes = getListTypes(schema);
  const listItemTypes = getListItemTypes(schema);

  const command = wrapInList(targetListType, attrs);

  const commandAdapter: Command = (state, dispatch) => {
    const result = command(state);

    if (result) {
      return command(state, dispatch);
    }

    const commonListNode = findCommonListNode(state, listTypes);

    if (!commonListNode) {
      return false;
    }

    if (dispatch) {
      const updatedNode = updateNode(
        commonListNode.node,
        targetListType,
        targetListItemType,
        listTypes,
        listItemTypes
      );

      let tr = state.tr;

      tr = tr.replaceRangeWith(
        commonListNode.from,
        commonListNode.to,
        updatedNode
      );

      tr = tr.setSelection(
        new TextSelection(
          tr.doc.resolve(state.selection.from),
          tr.doc.resolve(state.selection.to)
        )
      );

      dispatch(tr);
    }

    return true;
  };

  return {
    id,
    run: commandAdapter,
  };
}

buildWrapInListItem produces a command which runs the default wrapInList command if it can, and if the selected range already is a list, runs custom logic to switch the list type.

1 Like