Restore selection after replacing content

I have some top level containers blocks which can contain any block except itself. For example, the important one is defined like this:

nodes = nodes.addToEnd("important", {
    content: "(paragraph | heading | ordered_list | bullet_list)*",
    marks: "",
    group: "block",
    defining: true,
    parseDOM: [{ tag: 'div[data-theme="important"]' }],
    toDOM() {
        return ["div", { "data-role": "theme", "data-theme": "important" }, 0]
    },
})

I am writing an unwrap command that will remove the top most container block. I tried to use lift in many different way but I couldn’t properly remove the container.

I ended up with this code, which works except for restoring the selection:

export function unwrap(type) {
    return (state, dispatch, view) => {
        const tr = state.tr
        const $pos = state.selection.$anchor
        for (let d = $pos.depth; d > 0; d--) {
            const node = $pos.node(d)
            if (node.type === type) {
                const from = $pos.before(d)
                const to = $pos.after(d)
                if (dispatch) {
                    dispatch(
                        tr
                            .replaceWith(from, to, node.content.content)
                            .setSelection(
                                state.selection.map(tr.doc, tr.mapping),
                            )
                            .scrollIntoView(),
                    )
                }
                return true
            }
        }
        return false
    }
}

After applying this command, I end up with the selection/cursor after the block that was replaced.

How can I restore the selection where it was?

Don’t replace the whole range to unwrap something. Use Transaction.lift or build up a ReplaceAroundStep that only deletes the relevant open/close tokens.

I tried lift in different ways, but I always end up with an exception.

Uncaught RangeError: There is no position before the top-level node

With

export function unwrap(type) {
    return (state, dispatch, view) => {
        const $pos = state.selection.$anchor
        for (let d = $pos.depth; d > 0; d--) {
            const node = $pos.node(d)
            if (node.type === type) {
                const from = $pos.before(d)
                const to = $pos.after(d)
                let sel = TextSelection.create(state.doc, from, to)
                let range = sel.ranges[0]
                if (dispatch) {
                    dispatch(state.tr.lift(range, d))
                }
                return true
            }
        }
        return false
    }
}

I tried to offset from and to by one, and change depth to d-1 but it still yields this exception.

I will try the ReplaceAroundStep you mentioned.

I tried ReplaceAroundStep but I cannot understand how it works.

With this, it duplicates the block:

export function unwrap(type) {
    return (state, dispatch, view) => {
        const $pos = state.selection.$anchor
        for (let d = $pos.depth; d > 0; d--) {
            const node = $pos.node(d)
            if (node.type === type) {
                const from = $pos.before(d) +1
                const to = $pos.after(d) -1

                let sel = TextSelection.create(state.doc, from, to)
                let slice = sel.content()
                let step = new ReplaceAroundStep(from, to, from, to, slice, 0)
                if (dispatch) {
                    dispatch(state.tr.step(step))
                }
                return true
            }
        }
        return false
    }
}

After much tinkering, I could only get it working this way:


export function unwrap(type) {
    return (state, dispatch, view) => {
        const $pos = state.selection.$anchor
        const { $from } = state.selection
        for (let d = $pos.depth; d > 0; d--) {
            const node = $pos.node(d)
            if (node.type === type) {
                const children_nodes = node.content.content

                const tr = state.tr
                const pos = $from.before(d)
                tr.delete(pos, pos + node.nodeSize - 1)
                tr.insert(pos, children_nodes)
                tr.setSelection(TextSelection.create(tr.doc, $from.pos - 1))
                if (dispatch) {
                    dispatch(tr.scrollIntoView())
                }
                return true
            }
        }
        return false
    }
}