Replies: 6 comments 13 replies
-
is this one ( or part ) open source if so can you share the repo please thanks! |
Beta Was this translation helpful? Give feedback.
-
I am already building it here with Redux however I am facing some issues but I'll just keep going hopefully it ends up well! I have been working on it for over a week now and thanks for the reply! |
Beta Was this translation helpful? Give feedback.
-
@growthwp Thanks for sharing all this - it's helpful to know how to think through building a no-code editor. I can understand not sharing the source code to protect your ip. Would you be open to sharing the final product to see how it looks, esp since it's been a while since the original post? |
Beta Was this translation helpful? Give feedback.
-
@growthwp Where can we follow the updates for this? I can't wait to see it in action!! |
Beta Was this translation helpful? Give feedback.
-
would you please share the repo, thanks a lot😃! |
Beta Was this translation helpful? Give feedback.
-
Any demo links? |
Beta Was this translation helpful? Give feedback.
-
This is an extremely long, exhaustive read about how we created a completely full-fledged drag & drop page builder with what I believe is a very elegant solution, using
dnd-kit
. The concepts apply to practically any complex interaction you'd want, not just a page builder. At first, I'll be talking about how the drag & drop of items inside a page happens, moving a section on top of another and so on, and then, briefly explain how you'd want two seemingly unconnectedDnDContext
s to interact with one another.Introduction, long, skip if you don't care: About 1-2 years ago, we've kickstarted our efforts to create a page builder that trumps them all. Currently, WordPress' main mammoths don't hold a candle to big companies' page builders and are basically just toys. Their core code is also a total, complete disaster. Can't name them! Legal reasons and all that. As such, we needed something new. Picking
dnd-kit
was a no-brainer. It had lots of cool functionality that we could see used for a form builder, the sortable had lots of features and so on. Unfortunately, where it lacked (or so I thought) was creating very complex interactions, such as a page builder would need. Drag items on top of one another, before, after, to the right/left, add items, remove them and so on. Well, we did it and I believe it's a really clean approach.In just ~12 months, a team of two developers (but, really, one, in his spare time) have managed to actually build the features of all the other builders and then some more, of course, the DnD being just a smaller part. All API-ified, tested and the code is as good as one can hope to get - it's been re-written 3-4 times, after all. The project, overall, spans a crazy LoC of ~130k TS, it's mostly logic for how things interact and lots of mumbo-jumbo to create nice interactions. At this point in time, I can't offer a visual preview, as we're prepping for a huge release of a fleet of themes that will be using it and something tells me that the features we'll be offering will make a few folks mad, so, keeping that secret, but I can tell you that we've made lots of considerations, re-written the DnD pieces over and over and had 5/6 user-testing sessions for the DnD interactions alone.
Anyways, @clauderic has been extremely helpful to everyone and I thought that, if someone is wondering if you can create really complex interactions through
dnd-kit
, you can - and easily so.Communicating with the outside world.
For the OG's, back when
dnd-kit
was a child, we didn't have thedata
argument that you could pass touseDraggable
. Unfortunately, that meant you were constrained to "one dimension" of drag & drop. Couldn't really tell much of what was going on. Once it got introduced, it actually made everything practically possible. You could now tell all kinds of information about a drag event.Here's how we defined our "draggable handle" (more on why this is later):
In our system, an
editable
is asection | row | column | component
. Editables can be dragged around. A section after another section, a component inside a column, a row after another row and so on. We have a total of ~100-something interactions like this, even combining rows. The idea is as follows - when you define yourDndContext
, you have theonDragEnd
prop to decide what you want to happen when a drag event has finished. Ours looks like this:A few things to note - we're using Redux (RTK). The very first to fire is that "we're no longer dragging", when the user has let go of the click, whatever the outcome, we are done dragging. We keep track of this
isTracking
global variable so that we can prevent some actions from happening, enable some classes and so on, you get the point. Then, we check that there is both anactive
and anover
, if there aren't, it either means that:z-index
plays and so on.If all's well, we proceed. Notice how we're destructuring both of them to get both the
dragged
(the item that's being dragged)'sid
andtype
, as well as thedraggedOn
(what we dropped this draggable on). There's also theposition
argument, which I believe is where we could've done much better - as far as I was concerned, there was no easy way to tell which edge of the "dropped on" editable the user has dropped a draggable on, as such, our implementation of the "droppable area" is, really, an "edge":The
containerId
is our editable's unique id, theposition
is where this edge is (left, right, top, bottom...maybe more?) and the type is the editable's type. Remember, an edge only functions within an editable and is aposition: absolute
item inside that editable, which acts simply as a container. Here's a small preview of how an edge looks: https://i.imgur.com/7cLlwiZ.png - as I was explaining, the CSS looks something like this:And if we turn our attention to that
over
variable, which we're getting by usinguseDndContext
, you can see that we do a little check.(over?.id === `${containerId}_edge_${position}`)
, this basically ensure that we only run the code that comes next if our edge currently has a draggable overlay over it. If we don't do this check, they all fire up and show. Obviously, the next thing is to set the edge as visible. In our case, rows can only have two droppable areas, the top and the bottom, so, inside ourRow
component, we just render twoEdge
components, using an utility-componentDroppableArea
:In our case, we'd just simply do:
<DroppableArea edges={top: true, bottom: true} ... />
. To mention that, if you don't wrap your droppables like this, it tends to lead to very questionable behavior due to refs - don't ask me, I just figured why it happened and fixed it.Right, so, assuming the user is dragging something over our row, and it's on top of either the top/bottom edges, once the user lets go of the mouse button, the system sees a drag event has finished. Back to our
onDragEnd
- inside ourover
variable, we haveover.data.current
which contains - you guessed it - the data from the edge. We know that we're dropping onrow_0
, an editable of typerow
and we're dropping ontop
of it. What we drop is given, again, byactive.data.current
. Ultimately, the data we pass to - and this is where the brains begins - our handler is as follows:But the problem is now that we can't really do much with this. Well, we dispatch an action -
editableMoved
- to the Redux store, but before any data ends up there, it hits a reducer. Reducers are simply callbacks that respond to action being fired (in reality, they do something else, let's keep it simple) and then change the state somewhere. If we haverow_0
beforerow_1
and we wantrow_0
to be AFTERrow_1
, then, assuming we had an ordered list, obviously, we'd want it to look likerow_1, row_0
and then we just render it.So, we have these 5 variables from above. We need a central system that dictates what interactions are possible - you can't really drop a section inside a component, can you? :P
The very first thing that this action sees is our "find the callback for this specific interaction and then run it" reducer:
Notice that we do a few checks. First is,
!(draggedOnType in interactions)
, we don't want any unsupported draggable types to do anything, right? Ok, but what'sinteractions
, then? It's the brains of it all: the object that decides which editable can interact with which. Let's take a look:Right, so, we can see that
column
, acceptscomponent, column, row
. Going back to our code, the next check -(!interactions[draggedOnType].accepts.includes(draggedType))
does exactly what you think it does - it takes the item we're dragging on and looks inside itsaccepts
keys and checks the type of the item we dragged is inside there. If it is, it means that the interaction can happen. In our case, we're dragging a row on top of a row, so,row.accepts
does, indeed, includerow
. Great, so, it means we can proceed. Let's try and get a "DnD action callback" -const dndActionCallback = getActionCallback(draggedType, draggedOnType, position);
, but, hold on a second, we're now also asking for aposition
to be checked?Yep! So, you implement this at your discretion, and I can't share exactly why we chose to do things this way, because it's actually very, very smart if you're thinking about some things, but we have another "check if interactions are possible system". Let's take a look:
So, we're looking at a
draggedType
and then at adraggedOnType
and then at aposition
to see if it exists and return it if it does. Ouractions
has the following typings:(which should be typed differently,
string
doesn't really tell you much, but let's keep going) - so, we see that ourposition
key contains anAction
package (which, in turn, contains theActionCallback
):and the actual
callback
itself:...which is, again, just a function! Great, so, of course, we also have an
addAction
function somewhere, which simply adds one of these an populates ouractions
object. We use it as such:Which basically says that you want an action to happen every time a
section
is dropped on type of asection
and then that this specific callback should only run only if the position(s) is eithertop
orbottom
. So, where do these come from? Alright, we're going back to ourreducer
. Remember how we did all these checks to see if an editable supports an interaction with another editable, then we did this second round of tests to check if there's something to do for a valid interaction AND a valid position-interaction? Yea, the next line in our reducer wasdndActionCallback({ state, draggedId, draggedType, draggedOnId, draggedOnType, position });
- we're calling the exact callback that we added above. We're passing the current state (which is just Redux' store - we have to hold all this data somewhere, right?) and all the other items from before.And, well, if
onDragEnd
, the system finds that aneditable
of a certain type can interact with anothereditable
of some type, then, if it also finds that there is a callback registered for that interaction AND also theposition
representing where the user dropped that draggable, then it simply calls that callback. In my case, that callback figures out all that it needs in order to re-compute state such that it reflects what the user sees on the screen - moves entities around, removes/adds data and so on.The initial fear I had with
dnd-kit
was that it didn't give you many building blocks for data-rich interactions and I still think that very-very-very complex interactions are a bit hard, but this system makes sense and it's elegant.In short, play with the
data
argument and you'll figure it out from there on.But, then, most page builders have a sidebar and an item that you can drag on top of items that already exist on the page, how do you go about that? Again, the
data
argument for your draggable/droppable(s). All the same concepts above apply. While I believe this is a lie I made myself believe, if two items, no matter how far in the tree, have a DnD interaction, then they should be under the sameDndContext
. In reality, I think that shouldn't be the case, but, so far, I haven't seen issues.Just wanted to walk you through our decision-making and the struggles we had when trying to build a page builder - and we succeeded.
In short:
actionCallbacks
, ask for a function that will fire when a drop event happens.onDragEnd
) give the required data to our system: thecomponentId
(unique ID to identify the actual component an user sees), thecomponentType
- in our casesection | row | column | component
for both the dragged and the dropped-on components at a minimum.section
can interact withrow
(as in, a user has just dropped a section on a row - can the system do something with that?). If all's good, proceed.actionCallbacks
object for a callback: look by thedraggedType
's first, soactionCallbacks['section']
, then, by thedraggedOnType
, soactionCallbacks['section']['row']
and, finally, by position, soactionCallback['section']['row']['top']
. Inside youraddActionCallback
function, you should support the addition of items like this. We found this is the best way to define interactions in a page builder. So, youraddActionCallback
function should look something like this:row_1
insidesection_2
, we'll assume thatrow_1
will be the last one in the section and add its id after other rows' ids if they exist, so, ifsection_2
had rows with idsrow_3, row_4
, it would now berow_3, row_4, row_1
). Ultimately, these callbacks are the most important thing in your system - they decide how data is shuffled. Everything, so far, has been to support reaching this callback.Things I wish
dnd-kit
had and limitations you should be aware of (possibly, not entirely sure if we just simply don't know enough aboutdnd-kit
):Edge
component was only done in response to not being able to tell which side of the droppable a draggable was dropped on, and, then, not sure ifonDragOver
allows you to see anedge
-like position/information thingie.DndContext
'ssensors
or any of the collision detection algorithms on the fly, based on the item being dragged and so on. This is a limitation that I dislike. I wish there was some way. Some items in a page might prefer a certain collision technique than others.Beta Was this translation helpful? Give feedback.
All reactions