Node view that can be resized with drag handles

I’m trying to create a node view that wraps other inline nodes to allow users to select a section of the document (a “phrase”) to add some extra data to it. I would like users to be able to click and drag a resize handle (the “phrase delimiter”) on either end of the phrase to expand or contract it to change what content it wraps.

My strategy involves using the node view’s stopEvent to listen for “dragend” and check whether it is a phrase delimiter that is being dropped. It then calls a handlePhraseDelimiterDragEnd method with the type of delimiter (whether its the ‘opening’ or ‘closing’) and the document position where it was dropped.

I’m now stuck on the “resize node here” portion of the code below. I’m not exactly sure how to handle the transformation. If I have the positions of the original node, and the positions of the “new” node, I think I need to delete the original and replace it with a new node that contains all of the content between the new positions (maybe with replaceSelectionWith). Can anyone point me in the right direction?

const phraseSpec = {
    group: 'inline',
    content: 'inline+',
    draggable: false,
    inline: true,
    attrs: {
        id: { default: null }
    },
    parseDOM: [{
        tag: 'phrase',
        getAttrs: dom => (
            { id: dom.getAttribute('data-id') }
        )
    }],
    toDOM: node => (
        ['phrase', { 'data-id': node.attrs.id }, 0]
    )
}

class PhraseView {
    constructor(node, view, getPos) {
        this.node = node
        this.outerView = view
        this.getPos = getPos

        this.createDOMElements()
    }

    createDOMElements() {
        /**
         * <span class="phrase-node">
         *     <div class="phrase-delimiter" draggable="true" data-delimeter="opening" contenteditable="false"></div>
         *     <div class="phrase-content"><this.contentDOM></div>
         *     <div class="phrase-delimiter" draggable="true" data-delimeter="closing" contenteditable="false"></div>
         * </span>
         */
        this.dom = document.createElement('span')
        this.dom.className = 'phrase-node'

        this.openingPhraseDelimiter = document.createElement('div')
        this.openingPhraseDelimiter.className = 'phrase-delimiter'
        this.openingPhraseDelimiter.dataset.delimiter = 'opening'
        this.openingPhraseDelimiter.draggable = true
        this.openingPhraseDelimiter.contentEditable = false

        this.contentDOM = document.createElement('div')
        this.contentDOM.className = 'phrase-content'

        this.closingPhraseDelimiter = document.createElement('div')
        this.closingPhraseDelimiter.className = 'phrase-delimiter'
        this.closingPhraseDelimiter.dataset.delimiter = 'closing'
        this.closingPhraseDelimiter.draggable = true
        this.closingPhraseDelimiter.contentEditable = false

        this.dom.appendChild(this.openingPhraseDelimiter)
        this.dom.appendChild(this.contentDOM)
        this.dom.appendChild(this.closingPhraseDelimiter)
    }

    stopEvent(event) {
        if (event?.type === 'dragend') {
            const { srcElement } = event

            if (srcElement.classList.contains('phrase-delimiter')) {
                const draggedTo = this.outerView.posAtCoords({ left: event.x, top: event.y })

                return this.handlePhraseDelimiterDragEnd(srcElement.dataset.delimiter, draggedTo)
            }
        }
    }

    handlePhraseDelimiterDragEnd(delimiterType, draggedTo) {
        const tr = this.outerView.state.tr

        if (delimiterType === 'opening') {
            console.log('opening delimiter')

            // Resize node here

            return true
        } else {
            console.log('closing delimiter')

            // Resize node here

            return true
        }
    }
}

Edit:

The following code works to wrap the phrase when I move the opening delimiter, but it keeps the initial phrase node so I wind up with a phrase wrapped in a larger phrase…

// Resize node here
const resolvedDraggedTo = tr.doc.resolve(draggedTo.pos)
const resolvedPhraseEnd = tr.doc.resolve(this.getPos() + this.node.nodeSize)
const selection = new TextSelection(resolvedDraggedTo, resolvedPhraseEnd)

tr.setSelection(selection)
const phraseNode = this.outerView.state.doc.type.schema.nodes.phrase.create({ id: null }, selection.content().content)
tr.replaceSelectionWith(phraseNode, true)
this.outerView.dispatch(tr)

Second edit:

The solution below seems to work specifically for dragging the opening delimiter to the left to create a wider phrase node:

// Resize node here

// Get new delimeter positions
const openingDelimiterPos = draggedTo.pos
const closingDelimiterPos = this.getPos() + this.node.nodeSize

// 'Delete' existing phrase node (replace with its content)
tr.replaceWith(this.getPos(), closingDelimiterPos, this.node.content)

// 'Create' new phrase node
const resolvedOpeningDelimiter = tr.doc.resolve(openingDelimiterPos)
const resolvedClosingDelimiter = tr.doc.resolve(this.getPos())
const selection = new Selection(resolvedOpeningDelimiter, resolvedClosingDelimiter)
const selectionFragment = selection.content().content.content[0].content // Get only child content of the top-level paragraph block
const content = selectionFragment.append(this.node.content)
const newPhraseNode = tr.doc.type.schema.nodes.phrase.create({ id: this.node.attrs.id }, content)

tr.replaceWith(openingDelimiterPos, closingDelimiterPos, newPhraseNode)

this.outerView.dispatch(tr)
1 Like

The general idea would be to create a ReplaceAroundStep that drops the old opening/closing token, and creates a new one in the appropriate position. So for the start of the node, that’d be a replace-around step with, as slice, a node of that type with openStart=0 and openEnd=1 and its insert point at 1 when growing the node, and 0 when shrinking it, replacing the range that the start point was moved over, including the node’s existing open token, and with gapFrom and gapTo pointing at that same range but without the open token.

Do note that inline nodes with content will cause native editing on the node’s start and end to be somewhat unpredictable, and possibly require additional scripting to patch up.