Hey @philippkuehn ,
Yes, I did actually within an extensive long session and a lot of debugging and reading through the docs. Haha, Thank you for tiptap btw, I don’t use it in this current project, but I did in many other personal ones!
I’m not sure if I’m allowed to share the code, but I can walk you through what I did. My use case may differ from yours, so while what I’m about to tell you works for me, it might not be the answer (for implementation as a general solution within a library).
I figured it out, playing around and spending a lot of time on the code, while eventually, it turned out to be very easy for my use case.
I know you must be very advanced with Prosemirror, but I’ll do it verbosely just in case someone else with lesser knowledge tries to understand.
MultiSelection
My use case was to implement something very similar to how Notion.so makes multi-line selection, and to achieve that, I first added a plugin that attempts to appendTransaction
if, in any of the given trs
, there’s one that sets a new selection. This is really easy to check as each transaction has selectionSet
property and docChanged
.
Once this is done, and you’ve found the transaction you’re looking for, you want to ensure a couple of things, including:
- If the selection is not empty (
selection.empty
)
- If both
$anchor
and $head
are in different nodes (using .sameParent
on either $anchor
or $head
)
- If they have the same
depth
(this might not be necessary, but it was in my case)
Once you’ve evaluated these, you can return a new tr
. All we need to do now is to update the selection.
This part is a little bit tricky so read carefully. When moving the selection cursor backwards (bottom to top), these rules apply:
$anchor.pos > $head.pos (or anchor is bigger than head) and $from.pos < $to.pos (or from is smaller than to position)
anchor = to and head = from
While when spanning cursor from one node (whitin same depth) to another in forward direction (top to bottom) these apply:
$head.pos > $anchor.pos (or anchor is smaller than head) and $to.pos < $from.pos (or from is smaller than to position)
anchor = from and head = to
Now, knowing this, you can find the starting and the ending position of the node that either $anchor
or $head
is at by using start()
and end()
methods in ResolvedPos
and pass them the depth you want. So in my case, since both anchor and head are at the TextNode
within Paragraph
within Page
, I need to pass 1
, so it returns the end of Paragraph
. Then, depending on the given rules before, and if you’re moving backwards or forward, you can create a new TextSelection
and pass in the new positions, and you will be given a new transaction that you can return and Voilà.
It is essential that you use the tr
from newState
and not the one you found that changes selection; and the reason is, the transaction that does update the selection is one amongst many and might not be the latest one in the latest state, hence if used, it might result in Mismatched Transactions
.
Notion Like highlighting
This is easy; all you need to do is adding decorations to nodes currently located within the selection with doc.descendants
and adding a class to them that you can later use to add styles to the selected nodes.
Multiline Dragging
Actually, I found a perfect solution in a TipTap issue comment on Github. I have to mention that I needed to refactor this piece of code and make some adjustments. But what I think is essential to understand is that Prosemirror is smart enough to know that:
- If you have a text selection with
openEnd
and OpenStart
of a depth that is wrapping Nodes
and not TextNodes
, it will only allow you to drop it between nodes, meaning that the dropCursor
plugin only shows horizontal lines. (CMIIW, @marijn)
- If you have
NodeSelection
same rule applies as the first one
- If you have
TextSelection
that isn’t essentially wrapping Nodes
and only covers text, then it will allow you to drop it in between (or at the beginning or the end of) other TextNodes
.
NodeSelection vs TextSelection
So with that being told, now we understand that it is essential to have NodeSelection
for Block
dragging, which only works for selecting a single node, and TextSelection
that is wrapping Node
with correct openStart
and openEnd
depth for Multi-Line selection to work correctly.
Now, going back to the comment I mentioned above, I customised it so that whenever you start dragging the handler and the selection is empty, it will dispatch
a new transaction to the view, and make a new selection as NodeSelection
with the given node (for single-line block dragging), adds it to the slice. If the user has already selected multiple lines through the plugin I mentioned above, it will convert whatever is in the selection, add it to the slice, and start dragging. Something like this:
const coords = { left: e.clientX, top: e.clientY } // Coordinates of the where drag started
const position = view.posAtCoords(coords) // Position at the given coordinate
if (!selection.empty) {
// User has already selected multiple lines through the plugin
slice = selection.content()
view.dragging = { slice, move: true }
} else if (position) {
// User hasn't selected but dragged the handler beside given node at the given position
const newSelection = NodeSelection.create(doc, position.pos)
// Set NodeSelection for the given node
view.dispatch(tr.setSelection(newSelection).scrollIntoView())
slice = newSelection.content()
view.dragging = { slice, move: true }
}
I’d also suggest adding meta to trs
done by plugins through PluginKey
s, so that it is easier to filter them and work with them elsewhere.
Hope it helps.