diff --git a/CHANGELOG.md b/CHANGELOG.md index 04a103fc..2bcc4669 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- An app can now be dropped between two folders, on the top or bottom edge of a + folder row. + - Color fields now accept a hex code typed without its leading `#`, and say so when a value is not a color. diff --git a/ui/admin/index.html b/ui/admin/index.html index 7decc1cf..f2150179 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -303,6 +303,6 @@

General

- + diff --git a/ui/js/admin-drag-logic.js b/ui/js/admin-drag-logic.js index 4feccf92..77f6e703 100644 --- a/ui/js/admin-drag-logic.js +++ b/ui/js/admin-drag-logic.js @@ -5,6 +5,21 @@ export function canJoinFolder(type) { return type === 'app'; } +/* Share of a folder row's height at each edge that places the dragged row next + to the folder instead of inside it. */ +export const FOLDER_EDGE = 0.25; + +/** Which part of a folder row the pointer is over. + @param {number} y pointer position + @param {{ top: number, height: number }} rect the row's box + @returns {'above' | 'into' | 'below'} */ +export function folderRowZone(y, rect) { + const edge = rect.height * FOLDER_EDGE; + if (y < rect.top + edge) return 'above'; + if (y > rect.top + rect.height - edge) return 'below'; + return 'into'; +} + export function dropTargetKind({ srcType, targetIsFolder = false, indent = false }) { if (canJoinFolder(srcType) && (targetIsFolder || indent)) return 'into-folder'; return 'reorder'; diff --git a/ui/js/admin.js b/ui/js/admin.js index df4c91ff..bb874faa 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -1,6 +1,6 @@ import { buildAppForm, buildFolderForm, serializeKvRows } from '/js/admin-app-form.js?v=fde6479c'; import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=76be763e'; -import { applyDrop, canJoinFolder } from '/js/admin-drag-logic.js?v=8fcf583a'; +import { applyDrop, canJoinFolder, folderRowZone } from '/js/admin-drag-logic.js?v=ebe3e806'; import { reorderItems, resolveAdminSection } from '/js/admin-logic.js?v=f3f87abf'; import { buildAppItem, @@ -363,10 +363,11 @@ function mkRow(item, idx, { indent = false, childIdx = null, folderId = null } = e.preventDefault(); e.dataTransfer.dropEffect = 'move'; clearDragClasses(); + const rect = row.getBoundingClientRect(); if (row.dataset.isFolder && canJoinFolder(_dragType)) { - row.classList.add('drag-into'); + const zone = folderRowZone(e.clientY, rect); + row.classList.add(zone === 'into' ? 'drag-into' : zone === 'above' ? 'drag-above' : 'drag-below'); } else { - const rect = row.getBoundingClientRect(); row.classList.add(e.clientY < rect.top + rect.height / 2 ? 'drag-above' : 'drag-below'); } }); @@ -377,6 +378,7 @@ function mkRow(item, idx, { indent = false, childIdx = null, folderId = null } = row.addEventListener('drop', e => { e.preventDefault(); const dropAbove = row.classList.contains('drag-above'); + const dropInto = row.classList.contains('drag-into'); clearDragClasses(); const raw = e.dataTransfer.getData('text/plain'); if (!raw) return; @@ -388,7 +390,7 @@ function mkRow(item, idx, { indent = false, childIdx = null, folderId = null } = ...drop, targetId: item.id, targetFolderId: folderId, - targetIsFolder: item.type === 'folder', + targetIsFolder: item.type === 'folder' && dropInto, indent, childIdx, dropAbove, @@ -431,6 +433,7 @@ function wireTouchDrag(row, handle, { indent, folderId }) { const offY = e.clientY - startRect.top; let hovered = null, dropAbove = false, + dropInto = false, scrollTimer = null; const scroller = scrollParent(row); @@ -443,10 +446,14 @@ function wireTouchDrag(row, handle, { indent, folderId }) { const tr = /** @type {HTMLElement} */ (under && under.closest('.drow')); if (!tr || tr === row || tr === ghost) return; hovered = tr; + const r = tr.getBoundingClientRect(); if (tr.dataset.isFolder && canJoinFolder(itemType(srcId))) { - tr.classList.add('drag-into'); + const zone = folderRowZone(y, r); + dropInto = zone === 'into'; + dropAbove = zone === 'above'; + tr.classList.add(dropInto ? 'drag-into' : dropAbove ? 'drag-above' : 'drag-below'); } else { - const r = tr.getBoundingClientRect(); + dropInto = false; dropAbove = y < r.top + r.height / 2; tr.classList.add(dropAbove ? 'drag-above' : 'drag-below'); } @@ -485,14 +492,14 @@ function wireTouchDrag(row, handle, { indent, folderId }) { const tr = hovered; end(); if (!tr) return; - const into = tr.dataset.isFolder && canJoinFolder(itemType(srcId)); + const into = !!tr.dataset.isFolder && canJoinFolder(itemType(srcId)) && dropInto; const before = snapshotItems(state.items); const drop = applyDrop(state.items, { srcId, srcFolderId: indent ? folderId : null, targetId: tr.dataset.itemId, targetFolderId: tr.dataset.folderId || null, - targetIsFolder: !!tr.dataset.isFolder, + targetIsFolder: into, indent: !!tr.dataset.indent, childIdx: tr.dataset.childIdx != null ? Number(tr.dataset.childIdx) : null, dropAbove: into ? false : dropAbove, diff --git a/ui/test/admin-drag-logic.test.mjs b/ui/test/admin-drag-logic.test.mjs index 82d0451a..8d6504e5 100644 --- a/ui/test/admin-drag-logic.test.mjs +++ b/ui/test/admin-drag-logic.test.mjs @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { canJoinFolder, dropTargetKind, applyDrop } from '../js/admin-drag-logic.js'; +import { canJoinFolder, dropTargetKind, applyDrop, folderRowZone } from '../js/admin-drag-logic.js'; /* a, b, w top level; f is a folder holding c. */ const model = () => [ @@ -171,3 +171,30 @@ test('dropping an item on itself changes nothing', () => { ); assert.deepEqual(order(items), ['a', 'b', 'f', 'c', 'w']); }); + +const rect = { top: 100, height: 40 }; + +test('the edges of a folder row place a row next to it', () => { + assert.equal(folderRowZone(101, rect), 'above'); + assert.equal(folderRowZone(139, rect), 'below'); +}); + +test('the middle of a folder row still drops into the folder', () => { + for (const y of [111, 120, 129]) assert.equal(folderRowZone(y, rect), 'into'); +}); + +test('a drop on a folder edge reorders instead of joining the folder', () => { + const items = model(); + applyDrop(items, { + srcId: 'a', + srcFolderId: null, + targetId: 'f', + targetFolderId: null, + targetIsFolder: false, + indent: false, + childIdx: null, + dropAbove: true, + }); + assert.deepEqual(folder(items, 'f'), ['c']); + assert.deepEqual(order(items), ['b', 'a', 'f', 'c', 'w']); +});