From f600e857b022dc552706c2245ac059e03e9e963d Mon Sep 17 00:00:00 2001 From: jelveh Date: Fri, 31 Jul 2026 13:46:17 -0700 Subject: [PATCH] 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. --- src/gui/src/UI/Dashboard/TabApps.js | 159 +++++++++++++++++++++------- src/gui/src/css/dashboard.css | 155 ++++++++++++++++++++++++++- 2 files changed, 273 insertions(+), 41 deletions(-) diff --git a/src/gui/src/UI/Dashboard/TabApps.js b/src/gui/src/UI/Dashboard/TabApps.js index ac9c62693..cac73a9e4 100644 --- a/src/gui/src/UI/Dashboard/TabApps.js +++ b/src/gui/src/UI/Dashboard/TabApps.js @@ -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 = ''; + // 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 = `
`; h += '
'; h += ``; + // 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 += `'; + } h += '
'; h += `${html_encode(title)}`; h += '
'; @@ -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 += ''; h += ''; + // Touch devices' way into drag-to-reorder (CSS shows it only there); + // toggles the reorder mode — see _setReorderMode. + h += ``; h += ''; h += '
'; h += '
'; @@ -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 ) { diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css index d4f40feeb..1da5fc214 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -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 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;