Guidance on writing a command

I’ve been trying to write a command for a while now but I’m apparently not smart enough to figure it out myself. I’m hoping the generous experts in this forum could give me some guidance.

Basically what I want to do is cut out anything selected, split any inline node as well as the wrapping block node one level up, and insert another block node wrapping the selected content in that split. I’ve tried several different transactions but none have worked so far.

This is a simplified schema I’m using, omitting extra unnecessary content like marks and attributes.

let schema = new Schema({
	nodes: {
		doc: {
			content: 'question'
		},
		question: {
			content: 'block+',
			...
		},
		paragraph: {
			content: 'inline*',
			group: 'block',
			...
		},
		math_display: {
			group: 'block math',
			content: 'text*',
			atom: true,
			code: true,
		},
		text: {
			group: 'inline'
		}
	},
})

This is an example starting state of the document. Notice in the innermost text node I have positioned the selection $from and $to

const INITIAL_STATE = {
	type: 'doc',
	content: [
		{
			type: 'question',
			content: [
				{
					type: 'paragraph',
					content: [
						{
							type: 'text',
							text: 'Some $from text $to here'
						},
					]
				},
			]
		}
	]
}

And finally this is how I would like the state to end up.

const FINAL_STATE = {
	type: 'doc',
	content: [
		{
			type: 'question',
			content: [
				{
					type: 'paragraph',
					content: [
						{
							type: 'text',
							text: 'Some '
						},
					]
				},
				{
					type: 'math_display',
					content: [
						{
							type: 'text',
							text: '$from text $to'
						}
					]
				},
				{
					type: 'paragraph',
					content: [
						{
							type: 'text',
							text: ' here'
						},
					]
				},
			]
		}
	]
}

I’ve tried different combinations of using split, replaceSelectionWith, replaceWith, setBlockType… I’ve tried a lot of things but haven’t been able to get things to work right. I think part of the problem I’m having is that methods like replaceSelectionWith attempt to insert a math_display node into a paragraph node, which is not a valid operation.

The ideal approach here would be to create the correct ReplaceAroundStep, so that you don’t have to remove and then insert the selected content. For that, you’ll have to figure out the nodes that need to be closed before the new node, and create an empty version of them to put at the start of your inserted slice (setting openStart to its depth), add the wrapping nodes you want to insert after that to the slice, and then another node (or nested set of nodes) for the opening tokens needed after the new node (with openEnd set to its depth). This is a bit finicky, especially if you want to avoid creating empty nodes before and after the insertion when the selection reaches to the end/start of nodes, but should be doable.

Marijn, thanks for taking the time to respond. I’ve written a command that works sort of following your advice (I tried following your advice closely but kept breaking things so I just made things work), but it’s a very naive approach. Can you possibly make some suggestions to improve it? I’m sure I’m not using the API as efficiently as I should be.

export function insertDisplayMathCmd(displayMathNodeType: NodeType) {
	return function (state: EditorState, dispatch: ((tr: Transaction) => void) | undefined) {
		const { $from, $to } = state.selection;
		// Look at the parent of the text node to make sure it is compatible with display_math node
		// Only allowing splitting one level deep to keep things simple
		if (!$from.parent.type.compatibleContent(displayMathNodeType)) return false;

		// If selection is not empty, make sure $to has same parent node as $from to keep things simple
		if (!state.selection.empty && !$from.sameParent($to)) return false;

		// Since $from and $to should have same parent, range should be containing parent
		const range = $from.blockRange($to);
		if (!range) return false;

		// Is this not the same as both of the above together?
		// if (!$from.parent.canReplaceWith($from, $to, displayMathNodeType)) return false;

		if (dispatch) {
			let tr = state.tr;

			const contents: Node[] = [];

			// If we're not at the start of the parent node create the preceding node
			if ($from.parentOffset > 0) {
				// Node that needs to be closed before new node
				const preNode = $from.parent.type.create(
					$from.parent.attrs,
					$from.parent.slice(0, $from.parentOffset).content
				);
				contents.push(preNode);
			}

			// Create display_math node
			const initialText = state.selection.empty
				? null
				: $from.parent.textBetween($from.parentOffset, $to.parentOffset);
			const node = displayMathNodeType.create(
				{},
				initialText ? state.schema.text(initialText) : null
			);
			contents.push(node);

			// If we're not at the end of the parent node create the following node
			if ($to.parentOffset < $to.parent.content.size) {
				const postNode = $from.parent.type.create(
					$from.parent.attrs,
					$from.parent.slice($to.parentOffset).content
				);
				contents.push(postNode);
			}

			const fragment = Fragment.from(contents);
			const slice = new Slice(fragment, 0, 0);

			const step = new ReplaceAroundStep(
				range.start,
				range.end,
				range.start, //No preserved range because selected content was placed as text inside display_math node
				range.start,
				// state.selection.from,
				// state.selection.to,
				slice,
				0
			);

			tr.step(step);
			tr.setSelection(NodeSelection.create(tr.doc, $from.pos + 1));

			dispatch(tr);
		}
		return true;
	};

Thanks again for your patience and time.

The block range seems superfluous, but other than that, yes, this looks like a reasonable implementation. I agree it looks like it should be less complicated, but this kind of stuff seems hard to avoid in a generic interface for manipulating tree documents.