Prevent Drop Cursor to be shown in certain positions

Hello there, I have added an editor in which the schema consists of only pages; These pages act as blocks containing other nodes. So the root only contains Pages (page+). I also needed to implement drag and drop functionality to the app, only allowing nodes inside pages to be draggable and not the pages. What this means is that pages, are not anyhow moveable. To make this possible I implemented a plugin that makes multiline node selection (through TextSelection by having openEnd and openStart as 1 so that the dropCursor only shows horizontal lines). Users then can drag multiple lines together using a custom drag handler.

However, the problem is that users can drop their selection slices between pages, which is an unexpected behaviour.

Now, I have a couple of questions:

  1. Since I’m using drop-cursor plugin to show the drop cursor, how can I disable dropping slices between pages? I could think of adding a plugin and filter transactions that drop something between pages by getting the position and if it’s between pages, filter it, but this means they still can see the drop cursor between pages.
  2. Another way is using handleDrop prop, but I’m not sure how to do it, and if it would actually conflict with dropCursor plugin, or If I would be able to prevent the cursor from being shown.
  3. Another way for me is to customise the dropCursor plugin and change it so that it doesn’t show the drop cursor in specific positions. But I also think this isn’t a good way or a feasible one since there might be changes to the plugin, and I want to always keep it updated.
  4. How I can enforce the editor to remove paragraphs if they’ve been dragged elsewhere and now both paragraph and the parent page node are empty? This is only happening if I drag out the last paragraph(s) out of one page to another.

Here’s the schema for my document and page node:

Doc:

export const doc: NodeSpec = {
  content: 'page+'
}

Page:

export const page: NodeSpec = {
  content: 'block+',
  defining: true,
  isolating: true,
  parseDOM: [{ tag: 'div.page' }],
  toDOM () {
    return ['div', { class: 'page' }, 0]
  }
}

Paragraph:

export const paragraph: NodeSpec = {
  content: 'inline*',
  group: 'block',
  defining: true,
  parseDOM: [{ tag: 'p' }],
  toDOM () {
    return ['p', 0]
  }
}

Behaviours I want to prevent:

  1. Pay attention that the dashed line divides pages and drop cursor, shouldn’t be shown on top of it, and once I drop something, it automatically creates a new page node and adds a paragraph inside it.
  2. Page and Paragraph should be removed when dragging the last paragraph(s) out.

What’s happening now:

I’d appreciate any help or information on how I can prevent these unexpected behaviours.

The dropcursor plugin only shows that cursor, it does not handle dropping. It’s a very small plugin, so I think it’s reasonable to just copy its content and change it if you need it to behave differently.

Thanks for your reply @marijn. Got it, and then I assume, best would be to use handleDrop to cancel the events, and not filtering transactions in this case, am I right?

Also If I may ask for your opinion on question #4:

How can I enforce the editor to remove paragraphs if they’ve been dragged to another page node and both paragraph and the parent page node are empty? This is only happening if I drag out the last paragraph(s) out of one page to another.

I’m assuming it should be possible on schema level?

Agreed.

The schema allows you to forbid empty pages, but textblock nodes like paragraphs should typically be allowed to be empty, so forbidding pages-with-a-single-empty-paragraph is going to be tricky. If the problem happens during drop, maybe just write your custom drop handler to deal with this.

Awesome, got it, thanks a lot for your help.

Hey @mamsoudi, I’m working on a similar drag & drop behavior for tiptap. Have you managed to move multiple nodes (like two paragaphs) with a single drag handle?

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:

  1. If the selection is not empty (selection.empty)
  2. If both $anchor and $head are in different nodes (using .sameParent on either $anchor or $head)
  3. 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:

  1. 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)
  2. If you have NodeSelection same rule applies as the first one
  3. 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 PluginKeys, so that it is easier to filter them and work with them elsewhere.

Hope it helps.

3 Likes

Oh great! Thank you for this detailed explanation! I think this will also become part of tiptap at some point :slight_smile:

No problem, let me know if I could be of help!