From 80f18df48fcc39d22dfabb133e9bc823d7aeff86 Mon Sep 17 00:00:00 2001 From: Ramon Van Gorkom Date: Thu, 21 Nov 2024 21:34:14 +0100 Subject: [PATCH] luci-base: make items of UIDynamicList drag-sortable Signed-off-by: Ramon Van Gorkom --- .../htdocs/luci-static/resources/form.js | 4 +- .../htdocs/luci-static/resources/ui.js | 103 +++++++++++++++++- .../htdocs/luci-static/bootstrap/cascade.css | 29 ++++- .../htdocs/luci-static/material/cascade.css | 26 ++++- .../luci-static/openwrt2020/cascade.css | 28 ++++- .../luci-static/openwrt.org/cascade.css | 29 ++++- 6 files changed, 212 insertions(+), 7 deletions(-) diff --git a/modules/luci-base/htdocs/luci-static/resources/form.js b/modules/luci-base/htdocs/luci-static/resources/form.js index 7cf2ae75971c..94b45d7f766a 100644 --- a/modules/luci-base/htdocs/luci-static/resources/form.js +++ b/modules/luci-base/htdocs/luci-static/resources/form.js @@ -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) { diff --git a/modules/luci-base/htdocs/luci-static/resources/ui.js b/modules/luci-base/htdocs/luci-static/resources/ui.js index 8b4b1856d154..00643518dd19 100644 --- a/modules/luci-base/htdocs/luci-static/resources/ui.js +++ b/modules/luci-base/htdocs/luci-static/resources/ui.js @@ -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; diff --git a/themes/luci-theme-bootstrap/htdocs/luci-static/bootstrap/cascade.css b/themes/luci-theme-bootstrap/htdocs/luci-static/bootstrap/cascade.css index 48ec658e92aa..6a4e862f5c00 100644 --- a/themes/luci-theme-bootstrap/htdocs/luci-static/bootstrap/cascade.css +++ b/themes/luci-theme-bootstrap/htdocs/luci-static/bootstrap/cascade.css @@ -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; diff --git a/themes/luci-theme-material/htdocs/luci-static/material/cascade.css b/themes/luci-theme-material/htdocs/luci-static/material/cascade.css index fdcb98eb5b13..29d83bd42e4f 100644 --- a/themes/luci-theme-material/htdocs/luci-static/material/cascade.css +++ b/themes/luci-theme-material/htdocs/luci-static/material/cascade.css @@ -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%; diff --git a/themes/luci-theme-openwrt-2020/htdocs/luci-static/openwrt2020/cascade.css b/themes/luci-theme-openwrt-2020/htdocs/luci-static/openwrt2020/cascade.css index 46120e813128..3297b08681da 100644 --- a/themes/luci-theme-openwrt-2020/htdocs/luci-static/openwrt2020/cascade.css +++ b/themes/luci-theme-openwrt-2020/htdocs/luci-static/openwrt2020/cascade.css @@ -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; diff --git a/themes/luci-theme-openwrt/htdocs/luci-static/openwrt.org/cascade.css b/themes/luci-theme-openwrt/htdocs/luci-static/openwrt.org/cascade.css index 6ef9d63e72e3..2e7ba94aef21 100644 --- a/themes/luci-theme-openwrt/htdocs/luci-static/openwrt.org/cascade.css +++ b/themes/luci-theme-openwrt/htdocs/luci-static/openwrt.org/cascade.css @@ -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; }