SortableJS / Sortable

Reorderable drag-and-drop lists for modern browsers and touch devices. No jQuery or framework required.
https://sortablejs.github.io/Sortable/
MIT License
29.41k stars 3.69k forks source link

[bug] lit-html: Moving HTML comments creates issue with template system #2089

Open JaySunSyn opened 2 years ago

JaySunSyn commented 2 years ago

Describe the bug

Sortable changes HTML comments in the DOM and therefore reference points for some template systems. Some HTML template systems use HTML comments to render dynamic content. See: https://github.com/lit/lit/blob/main/packages/lit-html/src/lit-html.ts#L188

Issue: After calling destroy(), the template system can't work with the HTML anymore.

Workaround: Clear the whole HTML structure of the template and render the items again.

DOM before Sortable init:

image

DOM after Sortable init:

image

Expected behavior

The HTML comment structure keeps being intact.

Information

Not sure if this issue can ever get solved properly, but worth reporting I assumed :)

enkelmedia commented 8 months ago

@JaySunSyn I've spent quite a lot of time trying to create a "proxy" of the onEnd, onMove-events that would revert the changes to the DOM when an element is dragged/dropped. I ended up here when I saw some nasty bugs in certain scenarios when lit "forgets" about a DOM-element and basically ends up with duplicates.

I've seen people in the Vue-community having similar issues: https://github.com/SortableJS/Sortable/issues/546#issuecomment-1894271252

I guess I have two questions:

  1. Did you get anywhere with your attempts?

  2. Did you find another solution?

enkelmedia commented 7 months ago

I just wanted to get back here and share what I’ve learned and the approach that I took to get something that really works.

First of all, the problem that you outline @JaySunSyn is a real issue, these comments store information that Lit uses to be smart about how to update the DOM during update and we need to make sure that they are not tampered with.

The only way that I've found to make this work is to rely on Lit to do the DOM-reordering when the "drag" ends. So basically we need to restore the DOM to it's initial state when the drag ends, update the underlying array and make sure that Lit triggers a DOM-update.

I've created two elements drag-container and drag-item that work together and are rendered something like this

<drag-container group="controls" @dropped=${this.handleDropped}>
    <div class="items">
        ${this.items.map((item)=>html`
            <ns-drag-item>
                ${item.type}
            </ns-drag-item>
        `)}
    </div>
</ns-drag-container>

Inside the drag-container element I setup the Sortable, either on the drag-container it self or on the first div inside. However, I use a "proxied" version of Sortable that will store the DOM-structure when a drag starts and then revert it onEnd or onClone:

import Sortable from "sortablejs";

/*Lit-wrapper for SortableJS to revert DOM-manipulation*/
export const LitSortable = {
    create : create
}

function create(container : HTMLElement, options : Sortable.Options) : Sortable {

    let onStart = options.onStart;
    let onMove = options.onMove;
    let onEnd = options.onEnd;

    /** Stores the child-structure of a container element.
     *  key = container element
     *  value = .childNodes of that container when drag started
      */
    var map = new Map<HTMLElement,ChildNode[]>();

    options.onStart = (e)=> {

        // Create a new map to make sure no old childNode-state is kept from previous drags.
        map = new Map<HTMLElement,ChildNode[]>();

        // Store the childNodes of the container were the drag started.
        map.set(e.from, [...Array.prototype.slice.call(e.from.childNodes)]);

        if(onStart)
            onStart(e);

    }

    options.onMove = (e,originalEvent) => {

        // When entering this method the first, each container will have it's initial DOM-childNodes.
        // We only add each container to the map the first time we see it.

        if(!map.has(e.from))
            map.set(e.from, [...Array.prototype.slice.call(e.from.childNodes)]);

        if(!map.has(e.to))
            map.set(e.to, [...Array.prototype.slice.call(e.to.childNodes)]);

        if(onMove)
            onMove(e,originalEvent);
    }

    options.onClone = (e) => {

        if(!map.has(e.from))
            map.set(e.from, [...Array.prototype.slice.call(e.from.childNodes)]);

        if(!map.has(e.to))
            map.set(e.to, [...Array.prototype.slice.call(e.to.childNodes)]);

    }

    options.onEnd = (e)=> {

        // Here we loop over the map and restore the DOM-structure to it's initial state.
        // Something other than SortableJS will have to trigger Lit to update and re-order the children.

        map.forEach((children,container)=>{

            container.replaceChildren();
            children.forEach((child)=>{
                container.appendChild(child);
            });

        });

        if(onEnd)
            onEnd(e);
    }

    return Sortable.create(container,options);

}

I then use the LitSortable to create a new Sortable container:

import { LitSortable} from './lit-sortable';

this.sortable = LitSortable.create(container,options);

I really hope that this might help others that is looking to implement SortableJS with Lit.

jdcoldsmith commented 1 month ago

@enkelmedia Thank you for this very helpful solution! On my end this solution works but I am seeing a pretty noticable flickering when dragging items around due to the DOM swapping that is taking place. Have you noticed this as well and know of any solution?

jdcoldsmith commented 1 month ago

Actually after doing some more searching I found that this workaround actually does the trick with much less code and no screen flickering! https://lit.dev/playground/#gist=242f45fd2dbe21ecb6902f144686aae8

eviltik commented 1 week ago

@jdcoldsmith nice shot !! Working greats here