GUI: touch reorder mode for the dashboard Apps tab

Long-press-to-drag can't be made reliable on touch: touch-action is
consulted at gesture start, so the pager's pan-x claims the finger
before a drag can begin (worst on iOS). Replace it with an explicit
edit mode - a cog button (touch-primary devices only) enters it, tiles
jiggle and drag on first movement, iOS-style x badges uninstall, and
Done exits. Drops still persist immediately, same as desktop.

Also raise the drag ghost above the window z-index bands; it was
rendering invisibly behind the fullpage dashboard window.
This commit is contained in:
jelveh
2026-07-31 13:46:17 -07:00
parent 08d1708378
commit f600e857b0
2 changed files with 273 additions and 41 deletions
+120 -39
View File
@@ -32,9 +32,7 @@ const APP_NAMES_NO_UNINSTALL = new Set([
*/
// -- Drag-to-reorder tuning --
const DRAG_START_DISTANCE = 5; // px a mouse/pen must travel before a drag begins
const DRAG_TOUCH_CANCEL_DISTANCE = 10; // px of finger travel that reclassifies a press as a scroll
const DRAG_TOUCH_LONGPRESS_MS = 450; // hold time before a touch begins reordering
const DRAG_START_DISTANCE = 5; // px a pointer must travel before a drag begins
const DRAG_EDGE_ZONE = 60; // px from a scroller edge that arms a page flip
const DRAG_EDGE_DWELL_MS = 480; // hold time at an edge before the page flips
const DRAG_FLIP_SETTLE_MS = 440; // time to let a page flip's smooth-scroll settle
@@ -47,6 +45,10 @@ const TILE_REMOVE_DELAY_MS = 500; // pause between the uninstall modal closing
// around each tile is what stops items flickering back and forth at a boundary.
const DRAG_HIT_INSET = 0.28;
// Cog on the reorder-mode toggle; _setReorderMode swaps it for "Done" while
// the mode is on.
const REORDER_BTN_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>';
// External apps (not owned by a Puter user) can report an opaque app-… id
// as their title (uid === name === title); in that case show the hostname
// of index_url instead, and open the app's website (index_url) on click —
@@ -81,6 +83,13 @@ function buildTileHtml (app) {
let h = `<div class="myapps-tile" role="button" tabindex="-1" data-app-name="${html_encode(app.name)}" data-app-title="${html_encode(title)}" data-app-uid="${html_encode(app.uid || '')}" data-target-link="${html_encode(targetLink)}" title="${html_encode(title)}">`;
h += '<div class="myapps-tile-icon">';
h += `<img src="${html_encode(iconUrl)}" alt="" draggable="false">`;
// iOS-style uninstall badge; only shown while reorder mode is on (CSS).
// tabindex=-1 keeps it out of the grid's roving-tabindex tab order.
if ( ! APP_NAMES_NO_UNINSTALL.has((app.name || '').toLowerCase()) ) {
h += `<button type="button" class="myapps-tile-remove" tabindex="-1" aria-label="Uninstall ${html_encode(title)}">`;
h += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
h += '</button>';
}
h += '</div>';
h += `<span class="myapps-tile-label">${html_encode(title)}</span>`;
h += '</div>';
@@ -329,6 +338,7 @@ const TabApps = {
_pageCount: 0,
_hasCustomOrder: false,
_drag: null,
_reorderMode: false,
_justDragged: false,
_reduceMotionMQL: undefined,
_loadPromise: null,
@@ -347,6 +357,9 @@ const TabApps = {
// offering email/contact autofill suggestions on focus.
h += '<input type="search" name="myapps-search" class="myapps-search" placeholder="Search apps..." autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" data-form-type="other" data-lpignore="true" data-1p-ignore>';
h += '</div>';
// Touch devices' way into drag-to-reorder (CSS shows it only there);
// toggles the reorder mode — see _setReorderMode.
h += `<button type="button" class="myapps-reorder-btn" aria-pressed="false" aria-label="Edit apps">${REORDER_BTN_ICON}</button>`;
h += '</div>';
h += '<div class="myapps-container">';
h += '</div>';
@@ -355,10 +368,34 @@ const TabApps = {
},
init ($el_window) {
// This object outlives a closed dashboard window; a re-init gets a
// fresh DOM that is not in reorder mode, whatever the old one was.
this._reorderMode = false;
this.loadApps($el_window);
const self = this;
$el_window.on('click', '.myapps-reorder-btn', function () {
self._setReorderMode($el_window, ! self._reorderMode);
});
// Reorder mode's per-tile uninstall badge — the context-menu route to
// Uninstall is suppressed while the mode is on, this replaces it.
$el_window.on('click', '.myapps-tile-remove', function (e) {
e.preventDefault();
e.stopPropagation();
if ( ! self._reorderMode || self._drag ) return;
const $tile = $(this).closest('.myapps-tile');
showUninstallModal({
appName: $tile.attr('data-app-name'),
appTitle: $tile.attr('data-app-title'),
appUid: $tile.attr('data-app-uid'),
self,
$el_window,
});
});
// Tiles double as the app switcher for headless in-page apps: a
// dot marks tiles whose app has an open (or minimized) window.
// UIWindow fires this event on every window open/close.
@@ -385,6 +422,9 @@ const TabApps = {
$el_window.on('click', '.myapps-tile', function (e) {
e.preventDefault();
e.stopPropagation();
// In reorder mode a press on a tile is a (potential) drag pickup,
// never a launch.
if ( self._reorderMode ) return;
// A click synthesized at the end of a drag must not open the app.
if ( self._justDragged ) {
self._justDragged = false;
@@ -448,16 +488,18 @@ const TabApps = {
});
// Start a drag-to-reorder gesture. Kept separate from click so a plain
// click still opens the app (see _onTilePointerDown for the threshold /
// long-press logic that distinguishes the two).
// click still opens the app (see _onTilePointerDown for the movement
// threshold that distinguishes the two).
$el_window.on('pointerdown', '.myapps-tile', function (e) {
self._onTilePointerDown($el_window, e, this);
});
// Context menu on right-click
// Context menu on right-click (and, where the platform fires it,
// touch long-press).
$el_window.on('contextmenu', '.myapps-tile', function (e) {
// Suppress the menu (and any touch long-press callout) mid-drag.
if ( self._drag && self._drag.started ) {
// Reorder mode owns every tile gesture — no menu there; likewise
// suppress the menu (and any long-press callout) mid-drag.
if ( self._reorderMode || (self._drag && self._drag.started) ) {
e.preventDefault();
return;
}
@@ -512,10 +554,9 @@ const TabApps = {
});
}
// A touch long-press arms a drag pickup (see _onTilePointerDown). If
// the user held rather than dragged, they want this menu — cancel the
// pending pickup so the long-press → Uninstall path keeps working on
// touch. An already-started drag was handled by the guard above.
// A pending pickup (button held, not yet moved) would be stranded
// under the menu; cancel it so the two can't run at once. An
// already-started drag was handled by the guard above.
if ( self._drag ) self._endDrag(false);
e.preventDefault();
@@ -682,6 +723,16 @@ const TabApps = {
renderApps ($el_window, { preservePage = false, instant = false } = {}) {
if ( ! this._apps ) return;
// The reorder toggle only earns its place once there's something to
// reorder (CSS additionally gates it to touch-primary devices). A
// background refresh can also shrink the list below two mid-mode
// (e.g. apps uninstalled in another window) — leave the mode then.
$el_window.find('.myapps-reorder-btn')
.toggleClass('myapps-reorder-btn-available', this._apps.length >= 2);
if ( this._reorderMode && this._apps.length < 2 ) {
this._setReorderMode($el_window, false);
}
const $container = $el_window.find('.myapps-container');
const query = String($el_window.find('.myapps-search').val() || '').toLowerCase().trim();
@@ -811,6 +862,49 @@ const TabApps = {
return !! (this._reduceMotionMQL && this._reduceMotionMQL.matches);
},
// -- Reorder mode (touch) --
// On touch, a drag must win the gesture from native scrolling *before*
// the finger moves — touch-action is consulted at gesture start, so no
// amount of long-press arming can reclaim a touch the scroller already
// owns (hence the flaky pre-mode behavior, worst on iOS). An explicit
// mode can: while it's on, CSS sets touch-action:none on the tiles, the
// first pointer movement begins a drag, taps don't launch, and the
// context menu is suppressed. Each drop still persists immediately via
// saveOrder (same as desktop), so the Done button only exits the mode —
// there is no unsaved state to lose.
_setReorderMode ($el_window, on) {
on = !! on;
if ( this._reorderMode === on ) return;
if ( on && (! this._apps || this._apps.length < 2) ) return;
if ( ! on && this._drag ) {
// Done tapped with another finger mid-gesture: settle the drag
// first — commit a started one, discard a pending pickup.
this._endDrag(this._drag.started);
}
this._reorderMode = on;
$el_window.find('.dashboard-tab-content.myapps-tab')
.toggleClass('myapps-reorder-mode', on);
// Reordering a filtered subset is ambiguous (see _onTilePointerDown),
// so the mode owns the unfiltered grid: clear any query and freeze
// the search box while the mode is on.
const $search = $el_window.find('.myapps-search');
if ( on && String($search.val() || '') !== '' ) {
$search.val('');
this.updateSearchIcons($el_window);
this.renderApps($el_window);
}
$search.prop('disabled', on);
$el_window.find('.myapps-reorder-btn')
.toggleClass('myapps-reorder-btn-active', on)
.attr('aria-pressed', on ? 'true' : 'false')
.attr('aria-label', on ? 'Done editing' : 'Edit apps')
.html(on ? 'Done' : REORDER_BTN_ICON);
},
// -- Drag-to-reorder --
_onTilePointerDown ($el_window, e, tileEl) {
@@ -820,11 +914,20 @@ const TabApps = {
if ( oe.button !== undefined && oe.button !== 0 ) return;
if ( this._drag ) return;
if ( ! this._apps || this._apps.length < 2 ) return;
// A press on the uninstall badge belongs to that button, not to a
// drag pickup — a finger wobble while tapping × must not lift the tile.
if ( oe.target && oe.target.closest && oe.target.closest('.myapps-tile-remove') ) return;
// Reordering a filtered subset is ambiguous — only reorder the full list.
const query = String($el_window.find('.myapps-search').val() || '').trim();
if ( query ) return;
const pointerType = oe.pointerType || 'mouse';
// Touch reorders only inside reorder mode (the button is the way in;
// outside it the scroller owns touch gestures and would cancel the
// drag anyway — see _setReorderMode). A touch press outside the mode
// stays a tap (launch), swipe (page), or long-press (context menu on
// platforms that fire it), all handled elsewhere.
if ( pointerType === 'touch' && ! this._reorderMode ) return;
const d = this._drag = {
$el_window,
tileEl,
@@ -837,13 +940,11 @@ const TabApps = {
offsetX: 0,
offsetY: 0,
started: false,
readyToDrag: pointerType !== 'touch', // touch must long-press first
ghost: null,
edgeTimer: null,
edgeDir: 0,
flipping: false,
flipClearTimer: null,
longPressTimer: null,
};
// Ignore events from a second pointer (e.g. a stray finger) so it can't
@@ -860,21 +961,6 @@ const TabApps = {
document.addEventListener('pointercancel', d.onCancel);
document.addEventListener('keydown', d.onKey);
window.addEventListener('blur', d.onBlur);
// Touch: a long-press *arms* reordering (it doesn't grab the tile yet).
// Moving after that begins the drag; holding still instead lets the
// native long-press context menu (Uninstall) fire. A finger that moves
// before the long-press is a page swipe and cancels the intent (see
// _onDragPointerMove).
if ( pointerType === 'touch' ) {
d.longPressTimer = setTimeout(() => {
if ( this._drag !== d || d.started ) return;
d.readyToDrag = true;
if ( navigator.vibrate ) {
try { navigator.vibrate(8); } catch ( _e ) { /* not supported */ }
}
}, DRAG_TOUCH_LONGPRESS_MS);
}
},
_onDragPointerMove (e) {
@@ -883,11 +969,6 @@ const TabApps = {
if ( ! d.started ) {
const dist = Math.hypot(e.clientX - d.startX, e.clientY - d.startY);
if ( ! d.readyToDrag ) {
// Touch, pre-long-press: a moving finger is a page swipe.
if ( dist > DRAG_TOUCH_CANCEL_DISTANCE ) this._endDrag(false);
return;
}
if ( dist <= DRAG_START_DISTANCE ) return;
d.lastClientX = e.clientX;
d.lastClientY = e.clientY;
@@ -917,8 +998,9 @@ const TabApps = {
// starting a drag on a stale node would corrupt the persisted order.
if ( ! d.tileEl.isConnected ) { this._endDrag(false); return; }
d.started = true;
clearTimeout(d.longPressTimer);
d.longPressTimer = null;
if ( d.pointerType === 'touch' && navigator.vibrate ) {
try { navigator.vibrate(8); } catch ( _e ) { /* not supported */ }
}
const rect = d.tileEl.getBoundingClientRect();
d.offsetX = d.startX - rect.left;
@@ -1089,7 +1171,6 @@ const TabApps = {
document.removeEventListener('pointercancel', d.onCancel);
document.removeEventListener('keydown', d.onKey);
window.removeEventListener('blur', d.onBlur);
clearTimeout(d.longPressTimer);
clearTimeout(d.edgeTimer);
clearTimeout(d.flipClearTimer);
},
@@ -1113,8 +1194,8 @@ const TabApps = {
const names = d.$el_window.find('.myapps-page .myapps-tile').toArray()
.map(t => t.getAttribute('data-app-name'));
const current = this._apps.map(a => a.name);
// Only persist when the order actually changed, so an accidental
// long-press or drop-in-place doesn't freeze the default ordering.
// Only persist when the order actually changed, so a pickup
// dropped back in place doesn't freeze the default ordering.
changed = names.length !== current.length
|| names.some((name, i) => name !== current[i]);
if ( changed ) {
+153 -2
View File
@@ -791,6 +791,71 @@ input.myapps-search::-webkit-search-decoration {
appearance: none;
}
/* Frozen while reorder mode is on (see TabApps._setReorderMode). */
input.myapps-search:disabled {
opacity: 0.55;
}
/* Toggle for the touch reorder mode. Hidden by default: it only appears on
touch-primary devices (media query below — same test as
isTouchPrimaryDevice()) and only once there are 2+ tiles to reorder
(.myapps-reorder-btn-available, kept in sync by renderApps). */
.myapps-reorder-btn {
display: none;
flex: none;
-webkit-appearance: none;
appearance: none;
box-sizing: border-box;
padding: 8px 12px;
font-size: 13px;
font-weight: 500;
border: 1px solid var(--dashboard-border);
border-radius: 6px;
background: var(--dashboard-input-background);
color: var(--dashboard-text-primary);
cursor: pointer;
-webkit-user-select: none;
user-select: none;
touch-action: manipulation;
white-space: nowrap;
}
.myapps-reorder-btn svg {
display: block;
width: 16px;
height: 16px;
}
.myapps-reorder-btn.myapps-reorder-btn-active {
background: var(--select-color);
border-color: var(--select-color);
color: white;
}
@media (pointer: coarse) and (hover: none) {
.myapps-tab .myapps-reorder-btn.myapps-reorder-btn-available {
display: inline-flex;
align-items: center;
}
/* Sit the button beside the search box. With the button hidden (or on
devices where it never shows) the lone flex child centers at the same
360px max width the block layout gave it. */
.myapps-tab .myapps-search-wrap {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.myapps-tab .myapps-search-inner {
flex: 1 1 auto;
max-width: 360px;
margin-left: 0;
margin-right: 0;
}
}
.myapps-container {
/* Pager geometry; TabApps.js reads these to size pages. */
--myapps-tile-w: 100px;
@@ -988,6 +1053,8 @@ input.myapps-search::-webkit-search-decoration {
this, an overflowing label makes flexbox shrink the icon box, deforming
the icon into a landscape crop. */
flex: none;
/* Positioning context for the reorder-mode uninstall badge. */
position: relative;
width: 56px;
height: 56px;
overflow: hidden;
@@ -1394,13 +1461,17 @@ input.myapps-search::-webkit-search-decoration {
}
/* Floating clone that tracks the pointer. Lives on <body> so the scroller's
overflow can't clip it; positioned entirely via an inline transform. */
overflow can't clip it; positioned entirely via an inline transform.
z-index must clear every window band — the fullpage dashboard window sits
at ~1e7 and stay-on-top windows near 1e9 (see UIWindow), and a ghost that
loses that contest renders invisibly behind the very grid being dragged.
Max int is safe: the ghost is transient, purely visual, pointer-events:none. */
.myapps-drag-ghost {
position: fixed;
top: 0;
left: 0;
margin: 0;
z-index: 100000;
z-index: 2147483647;
pointer-events: none;
opacity: 0.95;
filter: drop-shadow(0 10px 20px var(--dashboard-shadow-medium));
@@ -1435,6 +1506,86 @@ body.myapps-reordering .myapps-tile {
touch-action: none;
}
/* -- Reorder mode (touch; see TabApps._setReorderMode) --
touch-action must be locked down BEFORE the finger goes down — the browser
reads it at gesture start, so the mid-drag body.myapps-reordering rule
above can't stop a touch the scroller has already claimed. While the mode
is on, touches that start on a tile belong to the drag logic (ones on the
gaps still pan pages natively) and the iOS long-press callout is off. */
.myapps-reorder-mode .myapps-tile {
touch-action: none;
-webkit-touch-callout: none;
}
/* iOS-style uninstall badge (see buildTileHtml); rendered on every tile that
may be uninstalled but only shown while reorder mode is on. Fixed dark
grey / white ×, like iOS, works over any theme. */
.myapps-tile-remove {
display: none;
position: absolute;
top: -7px;
left: -7px;
width: 22px;
height: 22px;
padding: 0;
border: none;
border-radius: 50%;
background: rgba(58, 58, 64, 0.92);
color: #fff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
cursor: pointer;
align-items: center;
justify-content: center;
z-index: 1;
touch-action: manipulation;
}
.myapps-tile-remove svg {
width: 11px;
height: 11px;
}
/* Pad the 22px badge out to a finger-sized target. */
.myapps-tile-remove::after {
content: '';
position: absolute;
inset: -7px;
}
.myapps-reorder-mode .myapps-tile-remove {
display: flex;
}
/* The badge overhangs the icon box's corner; the box normally clips. */
.myapps-reorder-mode .myapps-tile-icon {
overflow: visible;
}
/* iOS-home-screen jiggle: the visible cue that tiles are draggable. Applied
to the icon, not the tile, so it can't fight the FLIP translate transforms
the drag logic puts on the tiles themselves. Phases are staggered by grid
position so the tiles don't wobble in lockstep. */
@keyframes myapps-reorder-jiggle {
0% { transform: rotate(-1.5deg); }
50% { transform: rotate(1.5deg); }
100% { transform: rotate(-1.5deg); }
}
@media (prefers-reduced-motion: no-preference) {
.myapps-reorder-mode .myapps-tile .myapps-tile-icon {
animation: myapps-reorder-jiggle 0.35s ease-in-out infinite;
}
.myapps-reorder-mode .myapps-tile:nth-child(2n) .myapps-tile-icon {
animation-duration: 0.33s;
animation-delay: -0.12s;
}
.myapps-reorder-mode .myapps-tile:nth-child(3n) .myapps-tile-icon {
animation-delay: -0.24s;
}
}
@media (prefers-reduced-motion: reduce) {
.myapps-drag-ghost {
transition: none;