Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions e2e/wheel-paging.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// @ts-check
/* A trackpad swipe or a mouse's sideways wheel turns the page, once per
gesture, over the dashboard and over a widget alike. A widget is a frame, and
a wheel inside it never reaches the dashboard unless it is handed on. */

const { test, expect } = require('@playwright/test');
const { seedConfig, dismissSetupPrompt, app } = require('./helpers');

const MANY = Array.from({ length: 48 }, (_, i) => app(`a${i}`, `App ${i}`));
const clock = {
id: 'clock1',
type: 'widget',
label: 'Clock',
widgetType: 'clock',
widgetSize: 'small',
widgetConfig: {},
};

async function open(page, request) {
await seedConfig(request, { items: [clock, ...MANY], settings: { showLabels: { desktop: false } } });
await dismissSetupPrompt(request);
await page.route('**/api/badges', r => r.fulfill({ status: 200, contentType: 'application/json', body: '{}' }));
await page.route('**/api/health', r => r.fulfill({ status: 200, contentType: 'application/json', body: '{}' }));
await page.goto('/');
await page.locator('#dots .dot').nth(1).waitFor({ state: 'visible' });
await page.locator('.widget iframe').first().waitFor({ state: 'attached' });
await page.waitForFunction(() => {
const f = /** @type {HTMLIFrameElement|null} */ (document.querySelector('.widget iframe'));
return !!f && f.contentDocument?.readyState === 'complete';
});
}

const current = page => page.locator('#dots .dot').evaluateAll(ds => ds.findIndex(d => d.classList.contains('on')));

/* One step past the threshold. Headless WebKit can hold a step back longer than
the gap that ends a gesture, so a run of small steps can count as two. */
async function swipe(page, dx) {
await page.mouse.wheel(dx * 3, 0);
await page.waitForTimeout(400);
}

test('a sideways swipe turns one page, a vertical one none', async ({ page, request }) => {
await open(page, request);
const box = await page.locator('#dots').boundingBox();
await page.mouse.move((box?.x ?? 400) + 10, (box?.y ?? 400) - 40);
await page.mouse.wheel(0, 200);
await page.waitForTimeout(400);
await expect.poll(() => current(page)).toBe(0);
await swipe(page, 20);
await expect.poll(() => current(page)).toBe(1);
await swipe(page, -20);
await expect.poll(() => current(page)).toBe(0);
});

test('a sideways swipe over a widget turns the page too', async ({ page, request }) => {
await open(page, request);
const widget = await page.locator('.widget').first().boundingBox();
if (!widget) throw new Error('no widget on the first page');
await page.mouse.move(widget.x + widget.width / 2, widget.y + widget.height / 2);
await swipe(page, 20);
await expect.poll(() => current(page)).toBe(1);
});

test('a sideways swipe over an open folder leaves the page alone in the phone layout', async ({ page, request }) => {
const kids = Array.from({ length: 4 }, (_, i) => app(`f${i}`, `Folder app ${i}`));
const folder = { id: 'box', type: 'folder', label: 'Box', children: kids.map(c => c.id), color: 'dark' };
await seedConfig(request, { items: [folder, ...kids, ...MANY] });
await dismissSetupPrompt(request);
await page.setViewportSize({ width: 700, height: 844 });
await page.goto('/');
await page.locator('body.is-mob').waitFor({ state: 'attached' });
await page.locator('#dots .dot').nth(1).waitFor({ state: 'attached' });
await page.getByRole('button', { name: 'Box folder' }).click();
const overlay = page.locator('.folder-overlay-mobile');
await overlay.waitFor({ state: 'visible' });
const box = await overlay.boundingBox();
await page.mouse.move((box?.x ?? 0) + (box?.width ?? 0) / 2, (box?.y ?? 0) + (box?.height ?? 0) / 2);
await swipe(page, 20);
expect(await current(page)).toBe(0);
});

test('a swipe held up by a busy page still turns one page', async ({ page, request, browserName }) => {
test.skip(browserName === 'webkit', 'WebKit stamps a wheel event when it is handled, not when it was sent');
await open(page, request);
const box = await page.locator('#dots').boundingBox();
await page.mouse.move((box?.x ?? 400) + 10, (box?.y ?? 400) - 40);
await page.evaluate(() => {
let seen = 0;
addEventListener('wheel', () => {
if (++seen !== 2) return;
const t = performance.now();
while (performance.now() - t < 400);
});
});
const steps = [];
for (let i = 0; i < 8; i++) {
steps.push(page.mouse.wheel(20, 0));
await new Promise(r => setTimeout(r, 16));
}
await Promise.all(steps);
await page.waitForTimeout(400);
expect(await current(page)).toBe(1);
});
2 changes: 1 addition & 1 deletion ui/admin/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,6 @@ <h1 class="sec-title" id="ev-title">General</h1>
</div></div>
</div>

<script type="module" src="/js/admin.js?v=df22ef0c"></script>
<script type="module" src="/js/admin.js?v=afcb65c6"></script>
</body>
</html>
2 changes: 1 addition & 1 deletion ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -56,5 +56,5 @@
</div>
</div>
</dialog>
<script type="module" src="/js/dashboard.js?v=e45f7007"></script>
<script type="module" src="/js/dashboard.js?v=a81e3dbf"></script>
</body></html>
8 changes: 4 additions & 4 deletions ui/js/admin-app-form.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { clr, el, focusFirst, initial, inp as inpById, isolate, q as qSel, qa, qi, tgt } from '/js/utils.js?v=a282f4a9';
import { clr, el, focusFirst, initial, inp as inpById, isolate, q as qSel, qa, qi, tgt } from '/js/utils.js?v=0d64dfd3';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
import { loadLocalIcons, resolveIcon, iconChain, cdnIconRef, splitIconRef } from '/js/icons.js?v=9c7b5111';
import { state } from '/js/admin-state.js?v=af772a1b';
Expand All @@ -24,10 +24,10 @@ import {
reveal,
setTogDisabled,
swapContent,
} from '/js/admin-shared.js?v=352fe8b5';
import { createListbox } from '/js/listbox.js?v=c0187efc';
} from '/js/admin-shared.js?v=2b48ff3f';
import { createListbox } from '/js/listbox.js?v=35d8318a';
import { MAX_LABELS } from '/js/badge-logic.js?v=9e6d9d4b';
import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=63cc6464';
import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=3b14e88c';
import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=b0881957';
import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86';
import { iconSvg } from '/js/icon-set.js?v=628341d4';
Expand Down
4 changes: 2 additions & 2 deletions ui/js/admin-auth.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { apiGet, apiPost } from '/js/admin-shared.js?v=352fe8b5';
import { apiGet, apiPost } from '/js/admin-shared.js?v=2b48ff3f';
import { initI18n, t } from '/js/i18n.js?v=5579776a';
import { loginErrorKey } from '/js/admin-error.js?v=b0881957';
import { pwStrength } from '/js/password-strength.js?v=389e0ed0';
import { el, inp as inpById, qa } from '/js/utils.js?v=a282f4a9';
import { el, inp as inpById, qa } from '/js/utils.js?v=0d64dfd3';
import { blockingScreenFor } from '/js/config-recovery.js?v=dbe542e1';

export async function checkAuth(onLogin) {
Expand Down
4 changes: 2 additions & 2 deletions ui/js/admin-color-control.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=352fe8b5';
import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=2b48ff3f';
import { t } from '/js/i18n.js?v=5579776a';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
import { qa, q } from '/js/utils.js?v=a282f4a9';
import { qa, q } from '/js/utils.js?v=0d64dfd3';
import { iconSvg } from '/js/icon-set.js?v=628341d4';
import { HUE_NAMES, ROLE_NAMES, TILE_KEYWORDS, pageTheme, tileColor } from '/js/palette.js?v=3fb8ae43';

Expand Down
2 changes: 1 addition & 1 deletion ui/js/admin-drag.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import { applyDrop, canJoinFolder, folderRowZone } from '/js/admin-drag-logic.js?v=6b767e76';
import { state } from '/js/admin-state.js?v=af772a1b';
import { snapshotItems } from '/js/admin-save-logic.js?v=5811620e';
import { qa } from '/js/utils.js?v=a282f4a9';
import { qa } from '/js/utils.js?v=0d64dfd3';

/** @type {(before: unknown) => void} */
let _save = () => {};
Expand Down
8 changes: 4 additions & 4 deletions ui/js/admin-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@
import { collapsedFolders, filter, state } from '/js/admin-state.js?v=af772a1b';
import { snapshotItems } from '/js/admin-save-logic.js?v=5811620e';
import { reorderItems } from '/js/admin-logic.js?v=fc7f0836';
import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=c1e6f114';
import { paintIcon } from '/js/admin-shared.js?v=352fe8b5';
import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=a282f4a9';
import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=14a608cd';
import { paintIcon } from '/js/admin-shared.js?v=2b48ff3f';
import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=0d64dfd3';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
import { t } from '/js/i18n.js?v=5579776a';
import { sizeLabel } from '/js/admin-widget-form.js?v=79cb212b';
import { sizeLabel } from '/js/admin-widget-form.js?v=8bb8feb4';
import { widgetGlyph } from '/js/widget-glyphs.js?v=b383b7c8';
import { iconSvg } from '/js/icon-set.js?v=628341d4';

Expand Down
8 changes: 4 additions & 4 deletions ui/js/admin-settings.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { toast, apiGet, apiPost, errorText, reveal, swapContent } from '/js/admin-shared.js?v=352fe8b5';
import { toast, apiGet, apiPost, errorText, reveal, swapContent } from '/js/admin-shared.js?v=2b48ff3f';
import { socketProbeAdvice } from '/js/admin-error.js?v=b0881957';
import { pwStrength } from '/js/password-strength.js?v=389e0ed0';
import { t } from '/js/i18n.js?v=5579776a';
Expand All @@ -11,11 +11,11 @@ import {
BLOCK,
} from '/js/admin-logic.js?v=fc7f0836';
import { confirmText, promptModal } from '/js/modal.js?v=6b0320bd';
import { el, inp, setUserText } from '/js/utils.js?v=a282f4a9';
import { el, inp, setUserText } from '/js/utils.js?v=0d64dfd3';
import { formatNumber } from '/js/format-number.js?v=349a741d';
import { serialWrites } from '/js/admin-save-logic.js?v=5811620e';
import { renderColorControl } from '/js/admin-color-control.js?v=63cc6464';
import { BACKDROP } from '/js/background.js?v=9345e336';
import { renderColorControl } from '/js/admin-color-control.js?v=3b14e88c';
import { BACKDROP } from '/js/background.js?v=fe705356';
import {
ALLOWED_HOSTS_MAX,
firstBadHost,
Expand Down
2 changes: 1 addition & 1 deletion ui/js/admin-shared.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* Stateless helpers shared by the admin modules. Mutable state stays out. */
import { recoversSession, toastHoldMs } from '/js/admin-logic.js?v=fc7f0836';
import { el, isolate, q } from '/js/utils.js?v=a282f4a9';
import { el, isolate, q } from '/js/utils.js?v=0d64dfd3';
import { t } from '/js/i18n.js?v=5579776a';
import { errorAdvice } from '/js/admin-error.js?v=b0881957';
import { formatNumber } from '/js/format-number.js?v=349a741d';
Expand Down
8 changes: 4 additions & 4 deletions ui/js/admin-widget-form.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { state } from '/js/admin-state.js?v=af772a1b';
import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=352fe8b5';
import { createListbox } from '/js/listbox.js?v=c0187efc';
import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=92a828ab';
import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=2b48ff3f';
import { createListbox } from '/js/listbox.js?v=35d8318a';
import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=02ffdaba';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=fc7f0836';
import { t } from '/js/i18n.js?v=5579776a';
import { focusFirst, q, qi, qa } from '/js/utils.js?v=a282f4a9';
import { focusFirst, q, qi, qa } from '/js/utils.js?v=0d64dfd3';
import { iconSvg } from '/js/icon-set.js?v=628341d4';

const SIZES_WITH_ICONS = new Set(['small', 'medium', 'large', 'xlarge']);
Expand Down
18 changes: 9 additions & 9 deletions ui/js/admin.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=a457a197';
import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=3c46af12';
import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=7e288a05';
import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=db741421';
import { recoveryShown } from '/js/config-recovery.js?v=dbe542e1';
import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=35fcb4b0';
import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=e7e8ec6e';
import { resolveAdminSection } from '/js/admin-logic.js?v=fc7f0836';
import {
afterImport,
Expand All @@ -21,7 +21,7 @@ import {
settingsDirty,
showBgFields,
showWallpaperFile,
} from '/js/admin-settings.js?v=6bde63e6';
} from '/js/admin-settings.js?v=a5dc252e';
import {
apiGet,
apiPost,
Expand All @@ -35,13 +35,13 @@ import {
setReauthHandler,
toast,
withheldText,
} from '/js/admin-shared.js?v=352fe8b5';
} from '/js/admin-shared.js?v=2b48ff3f';
import { collapsedFolders, filter, state } from '/js/admin-state.js?v=af772a1b';
import { buildWidgetForm } from '/js/admin-widget-form.js?v=79cb212b';
import { buildWidgetForm } from '/js/admin-widget-form.js?v=8bb8feb4';
import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86';
import { formatNumber } from '/js/format-number.js?v=349a741d';
import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=8b39e9d0';
import { createListbox } from '/js/listbox.js?v=c0187efc';
import { createListbox } from '/js/listbox.js?v=35d8318a';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=5579776a';
import { loadLocalIcons } from '/js/icons.js?v=9c7b5111';
Expand Down Expand Up @@ -80,8 +80,8 @@ import {
storeGet,
storeSet,
tgt,
} from '/js/utils.js?v=a282f4a9';
import { applyBackground, resolveBackground } from '/js/background.js?v=9345e336';
} from '/js/utils.js?v=0d64dfd3';
import { applyBackground, resolveBackground } from '/js/background.js?v=fe705356';
import { parseYamlTolerant, YamlLiteError, YamlTooLargeError } from '/js/yaml-lite.js?v=a42ef914';

ensureSprite();
Expand Down
2 changes: 1 addition & 1 deletion ui/js/background.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// @ts-check
import { cssColor, sanitizeCssUrl } from '/js/utils.js?v=a282f4a9';
import { cssColor, sanitizeCssUrl } from '/js/utils.js?v=0d64dfd3';
import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6';

export const BACKDROP = '#0d1117';
Expand Down
24 changes: 24 additions & 0 deletions ui/js/dashboard-logic.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,27 @@ export function restorePage(stored, totalPages) {
export function landingAfterSetup(items) {
return Array.isArray(items) && items.length === 0 ? '/admin' : null;
}

/** One page per sideways wheel gesture. A trackpad swipe or a thumb wheel sends
a stream of events, momentum included. The page turns once the stream has
travelled far enough sideways, and not again until the stream pauses.

@param {{ threshold?: number, quietMs?: number }} [opts]
@returns {(dx: number, dy: number, t: number) => -1 | 0 | 1} */
export function wheelPager({ threshold = 40, quietMs = 220 } = {}) {
let sum = 0;
let last = Number.NEGATIVE_INFINITY;
let spent = false;
return (dx, dy, t) => {
if (t - last > quietMs) {
sum = 0;
spent = false;
}
last = t;
if (spent || Math.abs(dx) <= Math.abs(dy)) return 0;
sum += dx;
if (Math.abs(sum) < threshold) return 0;
spent = true;
return sum > 0 ? 1 : -1;
};
}
32 changes: 27 additions & 5 deletions ui/js/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@ import {
q,
qa,
qi,
scrollsSideways,
setUserText,
storeGet,
storeSet,
teardownWidgets,
titleWhenTruncated,
} from '/js/utils.js?v=a282f4a9';
} from '/js/utils.js?v=0d64dfd3';
import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86';
import { initSpotlight } from '/js/spotlight.js?v=2346ae96';
import { initSpotlight } from '/js/spotlight.js?v=e89d7f89';
import { html, setHtml, raw } from '/js/html.js?v=c71f8903';
import { initI18n, t, currentLang } from '/js/i18n.js?v=5579776a';
import { blockingScreenFor, showBlockingScreen } from '/js/config-recovery.js?v=dbe542e1';
Expand All @@ -45,7 +46,7 @@ import {
buildMobile,
resetMobileChrome,
mkFolderGlyph,
} from '/js/ui.js?v=f46cc64f';
} from '/js/ui.js?v=3c85d5d7';
import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=9e6d9d4b';
import { formatNumber } from '/js/format-number.js?v=349a741d';
import { closeBadgePopover, wireBadgePopover } from '/js/badge-popover.js?v=1e0ab5d1';
Expand All @@ -56,8 +57,9 @@ import {
desktopPages,
landingAfterSetup,
restorePage,
} from '/js/dashboard-logic.js?v=0d519f8b';
import { applyBackground, BACKDROP, resolveBackground } from '/js/background.js?v=9345e336';
wheelPager,
} from '/js/dashboard-logic.js?v=6a29cedc';
import { applyBackground, BACKDROP, resolveBackground } from '/js/background.js?v=fe705356';
import { repeatJittered } from '/js/jitter.js?v=087a1fcf';
import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=e9f4b607';
import { startWakeLock } from '/js/wake-lock.js?v=6b9591cf';
Expand Down Expand Up @@ -916,6 +918,26 @@ async function boot() {
if (e.key === 'ArrowRight') goTo(pg + pageDir());
if (e.key === 'ArrowLeft') goTo(pg - pageDir());
});
/* A trackpad swipe or a thumb wheel turns one page per gesture. Widget frames
hand their wheel to the card, so this also runs over widgets. */
const wheelStep = wheelPager();
document.addEventListener(
'wheel',
e => {
if (e.ctrlKey) return;
if (el('spot').classList.contains('on')) return;
if (document.querySelector('.folder-overlay, .folder-overlay-mobile')) return;
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? window.innerWidth : 1;
const dx = e.deltaX * unit,
dy = e.deltaY * unit;
if (Math.abs(dx) <= Math.abs(dy) || scrollsSideways(e.target, dx)) return;
/* Unhandled, a sideways swipe is the browser's back and forward gesture. */
e.preventDefault();
const step = wheelStep(dx, dy, e.timeStamp);
if (step) goTo(pg + step * pageDir());
},
{ passive: false },
);
let _dMx = 0,
_dDragging = false;
document.addEventListener('mousedown', e => {
Expand Down
2 changes: 1 addition & 1 deletion ui/js/listbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import { nextActiveIndex } from '/js/admin-logic.js?v=fc7f0836';
import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86';
import { html, raw, setHtml } from '/js/html.js?v=c71f8903';
import { qa } from '/js/utils.js?v=a282f4a9';
import { qa } from '/js/utils.js?v=0d64dfd3';
import { iconSvg } from '/js/icon-set.js?v=628341d4';

const CHEV = iconSvg('chevrons', 22, 'solid', 'dd-chev');
Expand Down
2 changes: 1 addition & 1 deletion ui/js/spotlight.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86';
import { mk, clr, el, initial, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=a282f4a9';
import { mk, clr, el, initial, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=0d64dfd3';
import { t } from '/js/i18n.js?v=5579776a';
import { formatNumber } from '/js/format-number.js?v=349a741d';

Expand Down
Loading
Loading