luci-base: make items of UIDynamicList drag-sortable
authorRamon Van Gorkom <Ramon00c00@gmail.com>
Thu, 21 Nov 2024 20:34:14 +0000 (21:34 +0100)
committerPaul Donald <newtwen+github@gmail.com>
Wed, 27 Nov 2024 20:43:16 +0000 (21:43 +0100)
Signed-off-by: Ramon Van Gorkom <Ramon00c00@gmail.com>
modules/luci-base/htdocs/luci-static/resources/form.js
modules/luci-base/htdocs/luci-static/resources/ui.js
themes/luci-theme-bootstrap/htdocs/luci-static/bootstrap/cascade.css
themes/luci-theme-material/htdocs/luci-static/material/cascade.css
themes/luci-theme-openwrt-2020/htdocs/luci-static/openwrt2020/cascade.css
themes/luci-theme-openwrt/htdocs/luci-static/openwrt.org/cascade.css

index 7cf2ae75971ca7cf7c56ae8101f9f8f20632537f..94b45d7f766afba391d88d43aff41f0a41ad11a2 100644 (file)
@@ -2752,7 +2752,6 @@ var CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection.p
        handleDragStart: function(ev) {
                if (!scope.dragState || !scope.dragState.node.classList.contains('drag-handle')) {
                        scope.dragState = null;
-                       ev.preventDefault();
                        return false;
                }
 
@@ -2763,6 +2762,7 @@ var CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection.p
 
        /** @private */
        handleDragOver: function(ev) {
+               if (scope.dragState === null ) return;
                var n = scope.dragState.targetNode,
                    r = scope.dragState.rect,
                    t = r.top + r.height / 2;
@@ -2783,6 +2783,7 @@ var CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection.p
 
        /** @private */
        handleDragEnter: function(ev) {
+               if (scope.dragState === null ) return;
                scope.dragState.rect = ev.currentTarget.getBoundingClientRect();
                scope.dragState.targetNode = ev.currentTarget;
        },
@@ -2808,6 +2809,7 @@ var CBITableSection = CBITypedSection.extend(/** @lends LuCI.form.TableSection.p
 
        /** @private */
        handleDrop: function(ev) {
+               if (scope.dragState === null ) return;
                var s = scope.dragState;
 
                if (s.node && s.targetNode) {
index 8b4b1856d1546664b36955034af8c3b4eeeefb70..00643518dd19b861675f4f3a0dc4731632c854ae 100644 (file)
@@ -2265,9 +2265,98 @@ var UIDynamicList = UIElement.extend(/** @lends LuCI.ui.DynamicList.prototype */
                        this.addItem(dl, this.values[i], label);
                }
 
+               this.initDragAndDrop(dl);
+
                return this.bind(dl);
        },
 
+       /** @private */
+       initDragAndDrop: function(dl) {
+               let draggedItem = null;
+               let placeholder = null;
+
+               dl.addEventListener('dragstart', (e) => {
+                       if (e.target.classList.contains('item')) {
+                               draggedItem = e.target;
+                               e.target.classList.add('dragging');
+                       }
+               });
+
+               dl.addEventListener('dragend', (e) => e.target.classList.remove('dragging'));
+
+               dl.addEventListener('dragover', (e) => e.preventDefault());
+
+               dl.addEventListener('dragenter', (e) => e.target.classList.add('drag-over'));
+
+               dl.addEventListener('dragleave', (e) => e.target.classList.remove('drag-over'));
+
+               dl.addEventListener('drop', (e) => {
+                       e.preventDefault();
+                       e.target.classList.remove('drag-over');
+                       const target = e.target.classList.contains('item') ? e.target : dl.querySelector('.add-item');
+                       dl.insertBefore(draggedItem, target);
+               });
+
+               dl.addEventListener('click', (e) => {
+                       if (e.target.closest('.item')) {
+                               const span = e.target.closest('.item').querySelector('SPAN');
+                               if (span) {
+                                       const range = document.createRange();
+                                       range.selectNodeContents(span);
+                                       const selection = window.getSelection();
+                                       if (selection.rangeCount === 0 || selection.toString().length === 0) {
+                                               selection.removeAllRanges();
+                                               selection.addRange(range);
+                                       } else selection.removeAllRanges();
+                               }
+                       }
+               });
+
+               dl.addEventListener('touchstart', (e) => {
+                       const touch = e.touches[0];
+                       const target = e.target.closest('.item');
+                       if (target) {
+                               draggedItem = target;
+
+                               placeholder = draggedItem.cloneNode(true);
+                               placeholder.className = 'placeholder';
+                               placeholder.style.height = `${draggedItem.offsetHeight}px`;
+                               draggedItem.parentNode.insertBefore(placeholder, draggedItem.nextSibling);
+                               draggedItem.classList.add('dragging')
+                       }
+               });
+
+               dl.addEventListener('touchmove', (e) => {
+                       if (draggedItem) {
+                               const touch = e.touches[0];
+                               const currentY = touch.clientY;
+
+                               const items = Array.from(dl.querySelectorAll('.item'));
+                               const target = items.find(item => {
+                                       const rect = item.getBoundingClientRect();
+                                       return currentY > rect.top && currentY < rect.bottom;
+                               });
+
+                               if (target && target !== draggedItem) {
+                                       const insertBefore = currentY < target.getBoundingClientRect().top + target.offsetHeight / 2;
+                                       dl.insertBefore(placeholder, insertBefore ? target : target.nextSibling);
+                               }
+
+                               e.preventDefault();
+                       }
+               });
+
+               dl.addEventListener('touchend', (e) => {
+                       if (draggedItem && placeholder) {
+                               dl.insertBefore(draggedItem, placeholder);
+                               draggedItem.classList.remove('dragging')
+                               placeholder.parentNode.removeChild(placeholder);
+                               placeholder = null;
+                               draggedItem = null;
+                       }
+               });
+       },
+
        /** @private */
        bind: function(dl) {
                dl.addEventListener('click', L.bind(this.handleClick, this));
@@ -2287,7 +2376,7 @@ var UIDynamicList = UIElement.extend(/** @lends LuCI.ui.DynamicList.prototype */
        /** @private */
        addItem: function(dl, value, text, flash) {
                var exists = false,
-                   new_item = E('div', { 'class': flash ? 'item flash' : 'item', 'tabindex': 0 }, [
+                   new_item = E('div', { 'class': flash ? 'item flash' : 'item', 'tabindex': 0, 'draggable': true }, [
                                E('span', {}, [ text || value ]),
                                E('input', {
                                        'type': 'hidden',
@@ -2359,7 +2448,17 @@ var UIDynamicList = UIElement.extend(/** @lends LuCI.ui.DynamicList.prototype */
                        return;
 
                if (item) {
-                       this.removeItem(dl, item);
+                       // Get bounding rectangle of the item
+                       var rect = item.getBoundingClientRect();
+
+                       // Get computed styles for the ::after pseudo-element
+                       var afterStyles = window.getComputedStyle(item, '::after');
+                       var afterWidth = parseFloat(afterStyles.width) || 0;
+
+                       // Check if the click is within the ::after region
+                       if (rect.right - ev.clientX <= afterWidth) {
+                               this.removeItem(dl, item);
+                       }
                }
                else if (matchesElem(ev.target, '.cbi-button-add')) {
                        var input = ev.target.previousElementSibling;
index 48ec658e92aad46281f248e4ab9fd50f3070986c..6a4e862f5c0007ec87ee72b6254312d52f53cf96 100644 (file)
@@ -624,9 +624,11 @@ select,
        border-radius: 3px;
        color: var(--text-color-high);
        position: relative;
-       pointer-events: none;
+       pointer-events: auto; /* needed for drag-and-drop in UIDynamicList */
        overflow: hidden;
        word-break: break-all;
+       cursor: move; /* drag-and-drop */
+       user-select: text; /* text selection in drag-and-drop */
 }
 
 .cbi-dynlist > .item::after {
@@ -645,10 +647,35 @@ select,
        pointer-events: auto;
 }
 
+/* indication line for drag-and-drop in UIDynamicList*/
+.cbi-dynlist > .item.drag-over { 
+       border-top: 1px solid var(--text-color-highest);
+}
+
+/* Make item being dragged in UIDynamicList partially transparent*/
+.cbi-dynlist > .item.dragging {
+       opacity: 0.5;
+}
+
+/* prevent pointer changing when over the span element in UIDynamicList */
+.cbi-dynlist > .item > span {
+       pointer-events: none;
+}
+
 .cbi-dynlist > .add-item {
        display: flex;
 }
 
+/* indication line for drag-and-drop in UIDynamicList*/
+.cbi-dynlist > .add-item > .cbi-input-text.drag-over {
+       border-top: 1px solid var(--text-color-highest);
+}
+
+/* indication line for drag-and-drop in UIDynamicList*/
+.cbi-dynlist > .add-item > .cbi-button-add.drag-over {
+       border-top: 1px solid var(--text-color-highest);
+}
+
 .cbi-dynlist > .add-item > input,
 .cbi-dynlist > .add-item > button {
        flex: 1 1 auto;
index fdcb98eb5b13f34ad39ba8c2ecf837a15e603bf7..29d83bd42e4ff212241e1a62f2dffa6f3bb2b447 100644 (file)
@@ -1378,10 +1378,12 @@ body:not(.Interfaces) .cbi-rowstyle-2:first-child {
        max-width: 25rem;
        margin-right: 2em;
        padding: .5em .25em .25em 0;
-       pointer-events: none;
+       pointer-events: auto; /* needed for drag-and-drop in UIDynamicList */
        color: #666;
        border-bottom: 2px solid rgba(0, 0, 0, .26);
        outline: 0;
+       cursor: move; /* drag-and-drop */
+       user-select: text; /* text selection in drag-and-drop */
 }
 
 .cbi-dynlist[name="sshkeys"] > .item {
@@ -1403,9 +1405,21 @@ body:not(.Interfaces) .cbi-rowstyle-2:first-child {
        background-color: var(--red-color-high);
 }
 
+/* indication line for drag-and-drop in UIDynamicList*/
+.cbi-dynlist > .item.drag-over {
+       border-top: 1px solid black;
+}
+
+/* Make item being dragged in UIDynamicList partially transparent*/
+.cbi-dynlist > .item.dragging {
+       opacity: 0.5;
+}
+
+/* prevent pointer changing when over the span element in UIDynamicList */
 .cbi-dynlist > .item > span {
        white-space: normal;
        word-break: break-word;
+       pointer-events: none;
 }
 
 .cbi-dynlist > .add-item {
@@ -1415,6 +1429,16 @@ body:not(.Interfaces) .cbi-rowstyle-2:first-child {
        min-width: 16rem;
 }
 
+/* indication line for drag-and-drop in UIDynamicList*/
+.cbi-dynlist > .add-item > .cbi-input-text.drag-over {
+       border-top: 1px solid black;
+}
+
+/* indication line for drag-and-drop in UIDynamicList*/
+.cbi-dynlist > .add-item > .cbi-button-add.drag-over {
+       border-top: 1px solid black;
+}
+
 .cbi-dynlist > .add-item:not([ondrop]) > input {
        overflow: hidden;
        width: 100%;
index 46120e813128938404fb118f3d2b9eb5b3e20b98..3297b08681da316e370908061963b94fe4207cd7 100644 (file)
@@ -1173,9 +1173,11 @@ textarea {
        position: relative;
        overflow: hidden;
        transition: box-shadow .25s ease-in-out;
-       pointer-events: none;
+       pointer-events: auto; /* needed for drag-and-drop in UIDynamicList */
        flex: 1 1 100%;
        word-break: break-all;
+       cursor: move; /* drag-and-drop */
+       user-select: text; /* text selection in drag-and-drop */
 }
 
 .cbi-dynlist > .item::after {
@@ -1196,6 +1198,20 @@ textarea {
        pointer-events: all;
 }
 
+/* indication line for drag-and-drop in UIDynamicList*/
+.cbi-dynlist > .item.drag-over {
+       border-top: 1px solid black;
+}
+
+/* Make item being dragged in UIDynamicList partially transparent*/
+.cbi-dynlist > .item.dragging {
+       opacity: 0.5;
+}
+/* prevent pointer changing when over the span element in UIDynamicList */
+.cbi-dynlist > .item > span {
+       pointer-events: none;
+}
+
 .cbi-dynlist[disabled] > .item::after {
        pointer-events: none;
 }
@@ -1209,6 +1225,16 @@ textarea {
        display: flex;
 }
 
+/* indication line for drag-and-drop in UIDynamicList*/
+.cbi-dynlist > .add-item > .cbi-input-text.drag-over {
+       border-top: 1px solid black;
+}
+
+/* indication line for drag-and-drop in UIDynamicList*/
+.cbi-dynlist > .add-item > .cbi-button-add.drag-over {
+       border-top: 1px solid black;
+}
+
 .cbi-dynlist > .add-item > input {
        flex: 1;
        min-width: 18.5rem;
index 6ef9d63e72e3aa8e861f69076a96a300040546f1..2e7ba94aef212b7059fa11db86d0f0ef2a8cbd77 100644 (file)
@@ -1340,10 +1340,12 @@ ul.cbi-tabmenu li.cbi-tab-disabled[data-errors]::after {
        border: 1px outset #000;
        border-radius: 3px;
        position: relative;
-       pointer-events: none;
+       pointer-events: auto; /* needed for drag-and-drop in UIDynamicList */
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
+       cursor: move; /* drag-and-drop */
+       user-select: text; /* text selection in drag-and-drop */
 }
 
 .cbi-dynlist > .item::after {
@@ -1364,10 +1366,35 @@ ul.cbi-tabmenu li.cbi-tab-disabled[data-errors]::after {
        height: auto;
 }
 
+/* indication line for drag-and-drop in UIDynamicList*/
+.cbi-dynlist > .item.drag-over {
+       border-top: 1px solid red;
+}
+
+/* Make item being dragged in UIDynamicList partially transparent*/
+.cbi-dynlist > .item.dragging {
+       opacity: 0.5;
+}
+
+/* prevent pointer changing when over the span element in UIDynamicList */
+.cbi-dynlist > .item > span {
+       pointer-events: none; 
+}
+
 .cbi-dynlist > .add-item {
        display: flex;
 }
 
+/* indication line for drag-and-drop in UIDynamicList*/
+.cbi-dynlist > .add-item > .cbi-input-text.drag-over {
+       border-top: 1px solid re;
+}
+
+/* indication line for drag-and-drop in UIDynamicList*/
+.cbi-dynlist > .add-item > .cbi-button-add.drag-over {
+       border-top: 1px solid red;
+}
+
 .cbi-dynlist > .add-item > input {
        flex: 1 1 auto;
 }