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)