mirror of
https://github.com/wekan/wekan.git
synced 2026-11-06 12:45:03 -05:00
The opened card and every popup move under a finger: Pointer Events and touch-action.
On an iPad the opened card did not move when dragged by a finger from the collapse caret, the space beside it, the card number, or the drag handle, while the page behind it scrolled. The card was moved by mousedown / mousemove / mouseup, and a finger sends none of them while it moves; the drag areas also lacked touch-action: none, so the browser took the gesture for a scroll. The card window is now moved by Pointer Events: a press on the header row or the handle moves it, a press on a link or button in the row (the caret, close, the menu) waits for a real drag so a tap still does its job and a drag does not also collapse or close, the title still edits, and with drag handles on the handle alone moves the window. The card number is above the title's edit zone so it drags too, the window is kept within reach, and touch-action: none is on the card header, its handle and every popup's title bar and handle. Blaze hands over a jQuery event without isPrimary, so the native event is read. A browser spec drags with a real touch (DevTools protocol, Pointer Events) from the caret, the number and the space beside the title, with handles on and off, a tap that still collapses, a mouse that still drags and a popup; without the fix the finger tests fail. Thanks to xet7 !
This commit is contained in:
1 parent
dafb63299f
commit
1b05673200
7 files changed
+504
-87
No files matched your search
@@ -250,6 +250,24 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
color: #b3b3b3;
|
||||
display: inline-block;
|
||||
margin-inline-end: 6px;
|
||||
/* Above the title's edit zone (z-index 1, over the whole title): a press on the
|
||||
card number moves the window like the rest of the header row; only the title
|
||||
itself edits. */
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* The areas that move the window by a finger. Without touch-action: none the browser
|
||||
takes a finger drag for a scroll and cancels the pointer, so the window never moves
|
||||
(the page behind it scrolls instead). Only in desktop mode, where the card is a
|
||||
window; the full-screen card of mobile mode is not moved. */
|
||||
body.desktop-mode .card-details .card-details-header,
|
||||
body.desktop-mode .card-details .card-details-header .card-drag-handle {
|
||||
touch-action: none;
|
||||
}
|
||||
body.card-window-dragging {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* Collapse toggle triangle */
|
||||
|
||||
@@ -81,6 +81,7 @@ const editingLocationId = new ReactiveVar(null);
|
||||
// Shared with Admin Panel, which links the places accounts log in from the same
|
||||
// way - models/lib/mapLink.js.
|
||||
const { mapLinkFor, GCJ } = require('/models/lib/mapLink');
|
||||
const { cardHeaderPress, dragThreshold, passedThreshold, clampWindow } = require('/models/lib/windowDrag');
|
||||
|
||||
|
||||
|
||||
@@ -1192,6 +1193,66 @@ Template.cardSectionHeader.events({
|
||||
},
|
||||
});
|
||||
|
||||
// Moves the card window under a finger or a mouse. `press` is cardHeaderPress's answer.
|
||||
// A press on a control waits until the pointer has really moved, so a tap still
|
||||
// collapses, closes or opens the menu; once it moved, the click that follows the drag
|
||||
// is swallowed.
|
||||
function beginCardWindowDrag(jqEvent, press) {
|
||||
// Blaze hands over a jQuery event, which does not copy `isPrimary`; the browser's
|
||||
// own event has it.
|
||||
const event = jqEvent.originalEvent || jqEvent;
|
||||
if (!press.start || !event.isPrimary) return;
|
||||
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
||||
|
||||
const $card = $(event.target).closest('.card-details');
|
||||
if (!$card.length) return;
|
||||
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const offset = $card.offset();
|
||||
const startLeft = offset.left - window.scrollX;
|
||||
const startTop = offset.top - window.scrollY;
|
||||
const threshold = dragThreshold(event.pointerType, press.deferred);
|
||||
const pointerId = event.pointerId;
|
||||
let moving = false;
|
||||
|
||||
const onMove = (e) => {
|
||||
if (e.pointerId !== pointerId) return;
|
||||
const dx = e.clientX - startX;
|
||||
const dy = e.clientY - startY;
|
||||
if (!moving) {
|
||||
if (!passedThreshold(dx, dy, threshold)) return;
|
||||
moving = true;
|
||||
// #6465: where the user put it wins over where the minicard is, so a later
|
||||
// resize re-places every other window but only clamps this one into view.
|
||||
markCardDetailsUserMoved($card);
|
||||
document.body.classList.add('card-window-dragging');
|
||||
}
|
||||
e.preventDefault();
|
||||
const placed = clampWindow(startLeft + dx, startTop + dy, $card.outerWidth(),
|
||||
{ width: window.innerWidth, height: window.innerHeight });
|
||||
$card.css({ left: `${placed.left}px`, top: `${placed.top}px` });
|
||||
};
|
||||
|
||||
const onEnd = (e) => {
|
||||
if (e.pointerId !== pointerId) return;
|
||||
document.removeEventListener('pointermove', onMove);
|
||||
document.removeEventListener('pointerup', onEnd);
|
||||
document.removeEventListener('pointercancel', onEnd);
|
||||
document.body.classList.remove('card-window-dragging');
|
||||
if (moving && press.deferred) {
|
||||
// The drag started on a link or a button: its click is not a tap.
|
||||
const swallow = (click) => { click.preventDefault(); click.stopPropagation(); };
|
||||
document.addEventListener('click', swallow, { capture: true, once: true });
|
||||
setTimeout(() => document.removeEventListener('click', swallow, { capture: true }), 400);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('pointermove', onMove, { passive: false });
|
||||
document.addEventListener('pointerup', onEnd);
|
||||
document.addEventListener('pointercancel', onEnd);
|
||||
}
|
||||
|
||||
Template.cardDetails.events({
|
||||
// #1591 folded each FIELD by its own title, storing a class per card in the
|
||||
// profile. The section carets replaced it: one handle per section, on the
|
||||
@@ -1221,86 +1282,31 @@ Template.cardDetails.events({
|
||||
Users.setPublicCardCollapsed(!currentState);
|
||||
}
|
||||
},
|
||||
'mousedown .js-card-drag-handle'(event) {
|
||||
event.preventDefault();
|
||||
const $card = $(event.target).closest('.card-details');
|
||||
// #6465: where the user put it wins over where the minicard is, so a later
|
||||
// resize re-places every other window but only clamps this one into view.
|
||||
markCardDetailsUserMoved($card);
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const startLeft = $card.offset().left;
|
||||
const startTop = $card.offset().top;
|
||||
|
||||
const onMouseMove = (e) => {
|
||||
const deltaX = e.clientX - startX;
|
||||
const deltaY = e.clientY - startY;
|
||||
$card.css({
|
||||
left: startLeft + deltaX + 'px',
|
||||
top: startTop + deltaY + 'px'
|
||||
});
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
$(document).off('mousemove', onMouseMove);
|
||||
$(document).off('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
$(document).on('mousemove', onMouseMove);
|
||||
$(document).on('mouseup', onMouseUp);
|
||||
// The opened card's window is moved by a finger as well as a mouse, so these are
|
||||
// Pointer Events (a finger sends no mouse events while it moves, which left the card
|
||||
// fixed on an iPad while the page behind it scrolled) and the areas that start a
|
||||
// drag are `touch-action: none` (cardDetails.css). With drag handles ON the handle
|
||||
// is the only thing that moves the window; with them off the header row moves it,
|
||||
// except the title (it edits). A link or button in the row - the collapse caret, the
|
||||
// menu, the card number's neighbours - still does its job when tapped, and moves the
|
||||
// window when dragged. models/lib/windowDrag.js
|
||||
'pointerdown .js-card-drag-handle'(event) {
|
||||
beginCardWindowDrag(event, { start: true, deferred: false });
|
||||
},
|
||||
'mousedown .js-card-title-drag-handle'(event) {
|
||||
// The title bar drags the window. Don't interfere with text selection.
|
||||
if (
|
||||
$(event.target).closest(
|
||||
'a, input, textarea, button, select, option, .js-card-details-title',
|
||||
).length > 0
|
||||
) {
|
||||
// #6641: while the editor replaces the header, a textarea press still
|
||||
// bubbles to this drag bar. Leave it to the browser so mouse dragging
|
||||
// selects text instead of moving the card window.
|
||||
return;
|
||||
}
|
||||
// #6839: ...and never from the title, which edits - all of it
|
||||
// (cardDetails.css). The window is moved from the empty space of the
|
||||
// header row above it, beside the collapse caret and the menu.
|
||||
if ($(event.target).closest('.card-details-title-edit-zone').length > 0) {
|
||||
return;
|
||||
}
|
||||
// The drag handle has its own handler; without this both would run and the
|
||||
// window would be moved twice by one press. And when there IS a handle -
|
||||
// drag handles on - it is the only thing that moves the window, which is
|
||||
// what turning handles on means.
|
||||
if ($(event.target).closest('.js-card-drag-handle').length > 0
|
||||
|| $(event.target).closest('.card-details').find('.js-card-drag-handle').length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const $card = $(event.target).closest('.card-details');
|
||||
// #6465: same as the drag handle above — a dragged window keeps its place.
|
||||
markCardDetailsUserMoved($card);
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const startLeft = $card.offset().left;
|
||||
const startTop = $card.offset().top;
|
||||
|
||||
const onMouseMove = (e) => {
|
||||
const deltaX = e.clientX - startX;
|
||||
const deltaY = e.clientY - startY;
|
||||
$card.css({
|
||||
left: startLeft + deltaX + 'px',
|
||||
top: startTop + deltaY + 'px'
|
||||
});
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
$(document).off('mousemove', onMouseMove);
|
||||
$(document).off('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
$(document).on('mousemove', onMouseMove);
|
||||
$(document).on('mouseup', onMouseUp);
|
||||
'pointerdown .js-card-title-drag-handle'(event) {
|
||||
const target = event.target;
|
||||
const closest = selector => (target.closest ? target.closest(selector) : null);
|
||||
const press = cardHeaderPress({
|
||||
onEditable: !!closest('input, textarea, select, option, .js-card-details-title'),
|
||||
onTitleEditZone: !!closest('.card-details-title-edit-zone'),
|
||||
// The handle has its own handler above; this one steps aside for it.
|
||||
onHandle: false,
|
||||
cardHasHandle: !!closest('.js-card-drag-handle')
|
||||
|| !!(closest('.card-details') && closest('.card-details').querySelector('.js-card-drag-handle')),
|
||||
onControl: !!closest('a, button'),
|
||||
});
|
||||
if (closest('.js-card-drag-handle')) return;
|
||||
beginCardWindowDrag(event, press);
|
||||
},
|
||||
'click .js-close-card-details'(event) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -39,6 +39,10 @@
|
||||
first row of a popup, and it must match the space beside it: `.content` has 18px of
|
||||
side padding, so 6 + 12 = 18 makes the top gap and the left gutter the same. */
|
||||
.pop-over .header {
|
||||
/* A popup is moved by its title bar (popup.js, Pointer Events). Without this a
|
||||
finger drag on it is taken for a scroll and the pointer is cancelled, so only a
|
||||
mouse could move it. */
|
||||
touch-action: none;
|
||||
height: calc(41px * var(--wekan-ui-font-scale, 1));
|
||||
position: relative;
|
||||
margin-bottom: 6px;
|
||||
@@ -1441,6 +1445,7 @@ body.mobile-mode .pop-over .content-wrapper {
|
||||
button (popup.js MOVABLE_POPUPS / MAXIMIZABLE_POPUPS), placed like the opened
|
||||
card's: the handle before the title, the button beside the close button. */
|
||||
.pop-over .header .popup-drag-handle {
|
||||
touch-action: none;
|
||||
float: inline-start;
|
||||
padding: 12px 0 12px 12px;
|
||||
cursor: move;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
'use strict';
|
||||
|
||||
// Moving a window - the opened card, a popup - with a finger as well as a mouse.
|
||||
// The decisions only, apart from the DOM, so plain Node tests can drive them.
|
||||
//
|
||||
// A mouse sends mousedown / mousemove / mouseup; a finger sends none of them while it
|
||||
// moves (only a click after a tap), so a window moved by those events could not be
|
||||
// moved on an iPad at all, while the page behind it scrolled. Pointer Events carry
|
||||
// both, and a drag area also needs `touch-action: none`, or the browser takes the
|
||||
// gesture for a scroll and cancels the pointer.
|
||||
|
||||
const TOUCH_THRESHOLD = 8; // px a finger may wobble in a tap
|
||||
const MOUSE_THRESHOLD = 4;
|
||||
|
||||
// What a press on the card's header row does. All booleans are about the pressed
|
||||
// element:
|
||||
// onEditable an input, textarea, select or option, or the open title editor:
|
||||
// the browser keeps those (text selection, caret)
|
||||
// onTitleEditZone the title, which edits - all of it
|
||||
// onHandle the drag handle
|
||||
// cardHasHandle drag handles are on for this card: the handle is then the only
|
||||
// thing that moves the window
|
||||
// onControl a link or button (the collapse caret, close, maximize, menu):
|
||||
// a tap does its job, a DRAG moves the window
|
||||
// Returns { start, deferred }: whether the press can move the window, and whether it
|
||||
// waits for the finger or mouse to move first (so a tap on a control still works).
|
||||
function cardHeaderPress({ onEditable, onTitleEditZone, onHandle, cardHasHandle, onControl }) {
|
||||
if (onHandle) return { start: true, deferred: false };
|
||||
if (onEditable || onTitleEditZone) return { start: false, deferred: false };
|
||||
if (cardHasHandle) return { start: false, deferred: false };
|
||||
|
||||
return { start: true, deferred: !!onControl };
|
||||
}
|
||||
|
||||
function dragThreshold(pointerType, deferred) {
|
||||
if (!deferred) return 0;
|
||||
|
||||
return pointerType === 'mouse' ? MOUSE_THRESHOLD : TOUCH_THRESHOLD;
|
||||
}
|
||||
|
||||
function passedThreshold(dx, dy, threshold) {
|
||||
return Math.hypot(dx, dy) >= threshold;
|
||||
}
|
||||
|
||||
// Keeps a window where it can be found again: at least `keep` px of its top-left
|
||||
// strip stays inside the viewport, so its header can always be grabbed.
|
||||
function clampWindow(left, top, width, viewport, keep = 48) {
|
||||
const minLeft = keep - width;
|
||||
const maxLeft = viewport.width - keep;
|
||||
const maxTop = viewport.height - keep;
|
||||
|
||||
return {
|
||||
left: Math.min(Math.max(left, minLeft), Math.max(minLeft, maxLeft)),
|
||||
top: Math.min(Math.max(top, 0), Math.max(0, maxTop)),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { cardHeaderPress, dragThreshold, passedThreshold, clampWindow, TOUCH_THRESHOLD, MOUSE_THRESHOLD };
|
||||
@@ -248,19 +248,99 @@ test('the bottom-right handle can make the opened card wider or narrower', () =>
|
||||
|
||||
test('a window the user dragged keeps its place, and is only kept on screen', () => {
|
||||
const js = read('client/components/cards/cardDetails.js');
|
||||
// Both drag handles — the header handle and the title — must mark it.
|
||||
const handles = [...js.matchAll(/'mousedown \.js-card-(?:title-)?drag-handle'\(event\) \{/g)];
|
||||
// Both drag handles - the header handle and the title - must mark it. They are
|
||||
// Pointer Events now (a finger sends no mouse events while it moves, so the mouse-only
|
||||
// handlers left the card fixed on an iPad); both start the same drag, beginCardWindowDrag,
|
||||
// and THAT marks the window as user-placed once it really moves.
|
||||
const handles = [...js.matchAll(/'pointerdown \.js-card-(?:title-)?drag-handle'\(event\) \{/g)];
|
||||
assert.strictEqual(handles.length, 2, 'both drag handles exist');
|
||||
for (const handle of handles) {
|
||||
// The whole handler, not a byte window: each of them starts with guards -
|
||||
// the title one steps aside for links and for the half of the title that
|
||||
// edits - and a fixed slice silently stops covering the thing being checked
|
||||
// the first time one of those grows.
|
||||
const end = js.indexOf('\n },', handle.index);
|
||||
const body = js.slice(handle.index, end === -1 ? js.length : end);
|
||||
assert.ok(/markCardDetailsUserMoved\(\$card\)/.test(body),
|
||||
'dragging must mark the window as user-placed');
|
||||
assert.ok(/beginCardWindowDrag\(event, /.test(body), 'each starts the shared drag');
|
||||
}
|
||||
const drag = js.slice(js.indexOf('function beginCardWindowDrag('), js.indexOf('Template.cardDetails.events({'));
|
||||
assert.ok(/markCardDetailsUserMoved\(\$card\)/.test(drag),
|
||||
'dragging must mark the window as user-placed');
|
||||
const at = js.indexOf('function anchorCardDetailsX(');
|
||||
assert.notStrictEqual(at, -1, 'the DOM half must exist');
|
||||
const body = js.slice(at, js.indexOf('\n}', at));
|
||||
for (const prop of ['top', 'bottom', 'height']) {
|
||||
assert.ok(!new RegExp(`el\\.style\\.${prop}\\s*=`).test(body),
|
||||
`it must not write style.${prop} — Y is already correct (#6465)`);
|
||||
}
|
||||
for (const prop of ['left', 'right', 'width']) {
|
||||
assert.ok(new RegExp(`el\\.style\\.${prop}\\s*=`).test(body), `it writes style.${prop}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── wiring ──────────────────────────────────────────────────────────────────
|
||||
|
||||
test('it runs when a card is opened, and again when the viewport changes', () => {
|
||||
const js = read('client/components/cards/cardDetails.js');
|
||||
assert.ok(/Tracker\.afterFlush\(\(\) => \{\s*const element = \$cardDetails\.get\(0\);\s*try \{\s*anchorCardDetailsX\(element\)/.test(js),
|
||||
'placed after the flush that renders it, so the measured width is the real one');
|
||||
assert.ok(/addEventListener\('resize'/.test(js) && /anchorAllCardDetailsX\(\)/.test(js),
|
||||
'and re-placed on resize — a viewport that shrank is the other way to lose it');
|
||||
assert.ok(/requestAnimationFrame/.test(js),
|
||||
'coalesced into a frame: a window drag-resize fires resize continuously');
|
||||
});
|
||||
|
||||
test('desktop cards stay unpainted until initial placement, including fallback paths', () => {
|
||||
const jade = read('client/components/cards/cardDetails.jade');
|
||||
const css = read('client/components/cards/cardDetails.css');
|
||||
const js = read('client/components/cards/cardDetails.js');
|
||||
// Drawn from a reactive helper, not as a static class: Blaze re-adds a
|
||||
// tag's static classes whenever its class attribute changes, so a static
|
||||
// pending class came back after Maximize / Minimize and hid the card.
|
||||
assert.match(jade, /section\.card-details\.js-card-details[^\n]*\(\s*class='\{\{#unless positionSettled\}\}card-details-position-pending\{\{\/unless\}\}'/);
|
||||
assert.doesNotMatch(jade, /section\.card-details[^\n(]*\.card-details-position-pending/,
|
||||
'negative: never a static class of the section');
|
||||
assert.match(js, /positionSettled = new ReactiveVar\(false\)/);
|
||||
assert.match(js, /element\.classList\.remove\('card-details-position-pending'\);[\s\S]*?tpl\.positionSettled\.set\(true\)/);
|
||||
assert.match(css, /body\.desktop-mode \.card-details\.card-details-position-pending:not\(\.card-details-popup\):not\(\.card-details-maximized\) \{\s*visibility: hidden;/);
|
||||
assert.match(js, /anchorCardDetailsX\(element\);\s*\} finally \{[\s\S]*?element\.classList\.remove\('card-details-position-pending'\)/);
|
||||
});
|
||||
|
||||
test('the bottom-right handle can make the opened card wider or narrower', () => {
|
||||
const css = read('client/components/cards/cardDetails.css');
|
||||
const desktopAt = css.indexOf(
|
||||
'body.desktop-mode .card-details:not(.card-details-popup) {',
|
||||
);
|
||||
const desktopRule = css.slice(desktopAt, css.indexOf('\n}', desktopAt));
|
||||
assert.match(desktopRule, /max-width: calc\(100vw - 16px\)/,
|
||||
'the desktop card can grow to the viewport edge');
|
||||
assert.doesNotMatch(desktopRule, /max-width: 520px/,
|
||||
'the initial width must not also be the resize ceiling');
|
||||
|
||||
const defaultAt = css.indexOf(
|
||||
'body.desktop-mode .card-details:not(.card-details-popup):not([style*="left"])',
|
||||
);
|
||||
const defaultRule = css.slice(defaultAt, css.indexOf('\n}', defaultAt));
|
||||
assert.match(defaultRule, /width: min\(520px, 90vw\)/,
|
||||
'the card still opens at its established compact width');
|
||||
assert.match(css, /@media screen and \(min-width: 801px\)[\s\S]*?\.card-details \{[\s\S]*?resize: both;/,
|
||||
'desktop users retain the two-way native resize handle');
|
||||
assert.match(css, /body\.mobile-mode \.card-details \{[\s\S]*?resize: none !important;/,
|
||||
'mobile remains full-screen rather than resizable');
|
||||
});
|
||||
|
||||
test('a window the user dragged keeps its place, and is only kept on screen', () => {
|
||||
const js = read('client/components/cards/cardDetails.js');
|
||||
// Both drag handles - the header handle and the title - must mark it. They are
|
||||
// Pointer Events now (a finger sends no mouse events while it moves, so the mouse-only
|
||||
// handlers left the card fixed on an iPad); both start the same drag, beginCardWindowDrag,
|
||||
// and THAT marks the window as user-placed once it really moves.
|
||||
const handles = [...js.matchAll(/'pointerdown \.js-card-(?:title-)?drag-handle'\(event\) \{/g)];
|
||||
assert.strictEqual(handles.length, 2, 'both drag handles exist');
|
||||
for (const handle of handles) {
|
||||
const end = js.indexOf('\n },', handle.index);
|
||||
const body = js.slice(handle.index, end === -1 ? js.length : end);
|
||||
assert.ok(/beginCardWindowDrag\(event, /.test(body), 'each starts the shared drag');
|
||||
}
|
||||
const drag = js.slice(js.indexOf('function beginCardWindowDrag('), js.indexOf('Template.cardDetails.events({'));
|
||||
assert.ok(/markCardDetailsUserMoved\(\$card\)/.test(drag),
|
||||
'dragging must mark the window as user-placed');
|
||||
const at = js.indexOf('function anchorCardDetailsX(');
|
||||
const body = js.slice(at, js.indexOf('\n}', at));
|
||||
assert.ok(/USER_MOVED_ATTR\) === '1'/.test(body), 'and the placement must honour it');
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
'use strict';
|
||||
|
||||
// The opened card's window moves under a FINGER. It was moved by mousedown / mousemove
|
||||
// / mouseup only; a finger sends none of them while it moves, so on an iPad the page
|
||||
// behind the card scrolled and the card stayed where it was - dragged from the collapse
|
||||
// caret, the space beside it, the card number, or the drag handle. Title editing is
|
||||
// unaffected (a tap on the title edits). Chromium only: the touch comes from the
|
||||
// DevTools protocol, which produces the same Pointer Events (pointerType "touch") a
|
||||
// finger does, and honours touch-action.
|
||||
const { test, expect } = require('../fixtures');
|
||||
const db = require('../helpers/db');
|
||||
const BoardPage = require('../pages/BoardPage');
|
||||
|
||||
async function touchDrag(cdp, from, to, steps = 12) {
|
||||
const point = (x, y) => ({ x, y, id: 1 });
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [point(from.x, from.y)] });
|
||||
for (let i = 1; i <= steps; i += 1) {
|
||||
await cdp.send('Input.dispatchTouchEvent', {
|
||||
type: 'touchMove',
|
||||
touchPoints: [point(from.x + ((to.x - from.x) * i) / steps, from.y + ((to.y - from.y) * i) / steps)],
|
||||
});
|
||||
}
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
}
|
||||
|
||||
const cardBox = page => page.evaluate(() => {
|
||||
const b = document.querySelector('.card-details').getBoundingClientRect();
|
||||
return { x: Math.round(b.x), y: Math.round(b.y) };
|
||||
});
|
||||
const centre = async locator => {
|
||||
const b = await locator.boundingBox();
|
||||
return { x: Math.round(b.x + b.width / 2), y: Math.round(b.y + b.height / 2) };
|
||||
};
|
||||
|
||||
async function openCardWindow(page, board, user, { handles }) {
|
||||
db.updateOne('users', { _id: user.id }, { $set: { 'profile.showDesktopDragHandles': handles } });
|
||||
db.updateOne('boards', { _id: board.boardId }, { $set: { allowsCardNumber: true } });
|
||||
await page.evaluate(() => localStorage.setItem('wekan-mobile-mode', 'false'));
|
||||
await page.reload();
|
||||
await new BoardPage(page).clickCard(board.listIds[0], 'Alpha Card');
|
||||
await expect(page.locator('.card-details')).toBeVisible();
|
||||
await expect(page.locator('body')).toHaveClass(/desktop-mode/);
|
||||
if (handles) await expect(page.locator('.js-card-drag-handle')).toBeVisible();
|
||||
else await expect(page.locator('.js-card-drag-handle')).toHaveCount(0);
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });
|
||||
return cdp;
|
||||
}
|
||||
|
||||
test('a finger moves the card window from the collapse caret, the space beside it and the card number', async ({ boardPage: page, board, user, browserName }) => {
|
||||
test.skip(browserName !== 'chromium', 'the touch comes from the DevTools protocol');
|
||||
const cdp = await openCardWindow(page, board, user, { handles: false });
|
||||
|
||||
const spots = {
|
||||
'the collapse caret': () => centre(page.locator('.card-details .card-collapse-toggle')),
|
||||
'the card number': () => centre(page.locator('.card-details .card-number')),
|
||||
'the space between the title and the menu': async () => {
|
||||
const title = await page.locator('.card-details .card-details-title').boundingBox();
|
||||
const menu = await page.locator('.card-details .js-open-card-details-menu').boundingBox();
|
||||
const header = await page.locator('.card-details .card-details-header').boundingBox();
|
||||
return { x: Math.round((title.x + title.width + menu.x) / 2), y: Math.round(header.y + 18) };
|
||||
},
|
||||
};
|
||||
|
||||
const inline = await page.evaluate(() => { const c = document.querySelector('.card-details'); return { left: c.style.left, top: c.style.top }; });
|
||||
for (const [name, where] of Object.entries(spots)) {
|
||||
const before = await cardBox(page);
|
||||
const from = await where();
|
||||
await touchDrag(cdp, from, { x: from.x - 120, y: from.y + 90 });
|
||||
await expect.poll(() => cardBox(page), { message: `a finger drag from ${name} moves the window` })
|
||||
.toMatchObject({ x: before.x - 120, y: before.y + 90 });
|
||||
// put it back for the next one
|
||||
await page.evaluate(({ left, top }) => { const c = document.querySelector('.card-details'); c.style.left = left; c.style.top = top; }, inline);
|
||||
await expect.poll(() => cardBox(page)).toMatchObject(before);
|
||||
}
|
||||
});
|
||||
|
||||
test('a tap on the collapse caret still collapses the card, and the title still edits', async ({ boardPage: page, board, user, browserName }) => {
|
||||
test.skip(browserName !== 'chromium', 'the touch comes from the DevTools protocol');
|
||||
const cdp = await openCardWindow(page, board, user, { handles: false });
|
||||
const before = await cardBox(page);
|
||||
|
||||
// A tap: press and release on the caret with no movement.
|
||||
const caret = await centre(page.locator('.card-details .card-collapse-toggle'));
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: caret.x, y: caret.y, id: 1 }] });
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||
await expect(page.locator('.card-details.card-details-collapsed'), 'the tap collapses it').toBeVisible();
|
||||
expect(await cardBox(page), 'and does not move it').toMatchObject(before);
|
||||
await page.locator('.card-details .card-collapse-toggle').click();
|
||||
await expect(page.locator('.card-details.card-details-collapsed')).toHaveCount(0);
|
||||
|
||||
// The title edits when it is tapped (not dragged from).
|
||||
await page.locator('.card-details .card-details-title').click();
|
||||
await expect(page.locator('.card-details textarea.js-edit-card-title, .card-details .js-card-details-title textarea')).toBeVisible();
|
||||
});
|
||||
|
||||
test('a drag that starts on the caret moves the window and does not also collapse the card', async ({ boardPage: page, board, user, browserName }) => {
|
||||
test.skip(browserName !== 'chromium', 'the touch comes from the DevTools protocol');
|
||||
const cdp = await openCardWindow(page, board, user, { handles: false });
|
||||
const before = await cardBox(page);
|
||||
const from = await centre(page.locator('.card-details .card-collapse-toggle'));
|
||||
await touchDrag(cdp, from, { x: from.x + 60, y: from.y + 40 });
|
||||
await expect.poll(() => cardBox(page), { message: 'it moved' }).toMatchObject({ x: before.x + 60, y: before.y + 40 });
|
||||
await expect(page.locator('.card-details.card-details-collapsed'), 'the click after a drag is swallowed').toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a popup moves under a finger by its title bar', async ({ boardPage: page, browserName }) => {
|
||||
test.skip(browserName !== 'chromium', 'the touch comes from the DevTools protocol');
|
||||
await page.locator('.js-open-header-member-menu').first().click();
|
||||
const header = page.locator('.pop-over .header').first();
|
||||
await expect(header).toBeVisible();
|
||||
const box = () => page.evaluate(() => { const b = document.querySelector('.pop-over').getBoundingClientRect(); return { x: Math.round(b.x), y: Math.round(b.y) }; });
|
||||
const before = await box();
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });
|
||||
// The title bar beside its title, away from the back and close buttons.
|
||||
const h = await header.boundingBox();
|
||||
const from = { x: Math.round(h.x + h.width / 2), y: Math.round(h.y + h.height / 2) };
|
||||
await touchDrag(cdp, from, { x: from.x - 90, y: from.y + 70 });
|
||||
await expect.poll(box, { message: 'a finger on the title bar moves the popup' }).toMatchObject({ x: before.x - 90, y: before.y + 70 });
|
||||
});
|
||||
|
||||
test('with drag handles on, the handle moves the window under a finger and the header row does not', async ({ boardPage: page, board, user, browserName }) => {
|
||||
test.skip(browserName !== 'chromium', 'the touch comes from the DevTools protocol');
|
||||
const cdp = await openCardWindow(page, board, user, { handles: true });
|
||||
const before = await cardBox(page);
|
||||
|
||||
// Negative: the header row is not the way when handles are on - not the caret (a
|
||||
// control, which would otherwise wait for a drag), not the card number.
|
||||
for (const locator of [page.locator('.card-details .card-collapse-toggle'), page.locator('.card-details .card-number')]) {
|
||||
const from = await centre(locator);
|
||||
await touchDrag(cdp, from, { x: from.x - 100, y: from.y + 80 });
|
||||
expect(await cardBox(page), 'the header row does not move it').toMatchObject(before);
|
||||
}
|
||||
|
||||
// The handle does.
|
||||
const handle = await centre(page.locator('.card-details .js-card-drag-handle'));
|
||||
await touchDrag(cdp, handle, { x: handle.x - 140, y: handle.y + 100 });
|
||||
await expect.poll(() => cardBox(page), { message: 'a finger on the handle moves the window' })
|
||||
.toMatchObject({ x: before.x - 140, y: before.y + 100 });
|
||||
});
|
||||
|
||||
test('a mouse still moves the window by the header row', async ({ boardPage: page, board, user }) => {
|
||||
await openCardWindow(page, board, user, { handles: false });
|
||||
const before = await cardBox(page);
|
||||
const header = await page.locator('.card-details .card-details-header').boundingBox();
|
||||
const title = await page.locator('.card-details .card-details-title').boundingBox();
|
||||
const menu = await page.locator('.card-details .js-open-card-details-menu').boundingBox();
|
||||
const x = Math.round((title.x + title.width + menu.x) / 2);
|
||||
const y = Math.round(header.y + 18);
|
||||
await page.mouse.move(x, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(x - 80, y + 60, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
await expect.poll(() => cardBox(page)).toMatchObject({ x: before.x - 80, y: before.y + 60 });
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
'use strict';
|
||||
|
||||
// The opened card, and every popup, move under a finger as well as a mouse. The card
|
||||
// was moved by mousedown / mousemove / mouseup only; a finger sends none of them while
|
||||
// it moves, so on an iPad the page behind scrolled and the card stayed where it was -
|
||||
// from the collapse caret, the space beside it, the card number, and from the drag
|
||||
// handle too. Pointer Events carry both, and the drag areas need touch-action: none.
|
||||
// models/lib/windowDrag.js is the decisions; the wiring is read from the source.
|
||||
// tests/playwright/specs/card-window-touch-drag.e2e.js drags with a real touch.
|
||||
//
|
||||
// Run: node tests/windowDragTouch.test.cjs
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
const { cardHeaderPress, dragThreshold, passedThreshold, clampWindow } = require('../models/lib/windowDrag');
|
||||
|
||||
const read = rel => fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
|
||||
const none = { onEditable: false, onTitleEditZone: false, onHandle: false, cardHasHandle: false, onControl: false };
|
||||
|
||||
test('with drag handles off, the header row moves the window - and a control waits for a real drag', () => {
|
||||
// empty space of the row, beside the caret
|
||||
assert.deepEqual(cardHeaderPress({ ...none }), { start: true, deferred: false });
|
||||
// the collapse caret, the menu, close: a tap still does its job, a drag moves the window
|
||||
assert.deepEqual(cardHeaderPress({ ...none, onControl: true }), { start: true, deferred: true });
|
||||
});
|
||||
|
||||
test('the title edits, and an open editor keeps its text selection (negative)', () => {
|
||||
assert.deepEqual(cardHeaderPress({ ...none, onTitleEditZone: true }), { start: false, deferred: false });
|
||||
assert.deepEqual(cardHeaderPress({ ...none, onEditable: true }), { start: false, deferred: false });
|
||||
assert.deepEqual(cardHeaderPress({ ...none, onEditable: true, onControl: true }), { start: false, deferred: false });
|
||||
});
|
||||
|
||||
test('with drag handles on, only the handle moves the window', () => {
|
||||
assert.deepEqual(cardHeaderPress({ ...none, onHandle: true, cardHasHandle: true }), { start: true, deferred: false });
|
||||
// negative: the header row does not
|
||||
assert.deepEqual(cardHeaderPress({ ...none, cardHasHandle: true }), { start: false, deferred: false });
|
||||
assert.deepEqual(cardHeaderPress({ ...none, cardHasHandle: true, onControl: true }), { start: false, deferred: false });
|
||||
});
|
||||
|
||||
test('a finger may wobble more than a mouse before a press on a control becomes a drag', () => {
|
||||
assert.equal(dragThreshold('touch', false), 0, 'a press that is not on a control moves at once');
|
||||
assert.equal(dragThreshold('mouse', true), 4);
|
||||
assert.equal(dragThreshold('touch', true), 8);
|
||||
assert.equal(dragThreshold('pen', true), 8);
|
||||
assert.equal(passedThreshold(3, 3, 8), false, 'a 4 px wobble is a tap');
|
||||
assert.equal(passedThreshold(6, 6, 8), true);
|
||||
assert.equal(passedThreshold(0, 0, 0), true, 'threshold 0: any move');
|
||||
});
|
||||
|
||||
test('the window cannot be dragged out of reach', () => {
|
||||
const viewport = { width: 1000, height: 800 };
|
||||
assert.deepEqual(clampWindow(100, 50, 520, viewport), { left: 100, top: 50 }, 'inside: unchanged');
|
||||
assert.deepEqual(clampWindow(-2000, -300, 520, viewport), { left: 48 - 520, top: 0 }, 'left of the window: its right strip stays');
|
||||
assert.deepEqual(clampWindow(5000, 5000, 520, viewport), { left: 1000 - 48, top: 800 - 48 }, 'right and below: its top-left strip stays');
|
||||
// a viewport narrower than the strip does not produce NaN
|
||||
const tiny = clampWindow(10, 10, 300, { width: 20, height: 20 });
|
||||
assert.ok(Number.isFinite(tiny.left) && Number.isFinite(tiny.top));
|
||||
});
|
||||
|
||||
test('the card window is moved by Pointer Events, and no mouse-only drag is left', () => {
|
||||
const js = read('client/components/cards/cardDetails.js');
|
||||
assert.match(js, /'pointerdown \.js-card-drag-handle'\(event\)/);
|
||||
assert.match(js, /'pointerdown \.js-card-title-drag-handle'\(event\)/);
|
||||
assert.match(js, /document\.addEventListener\('pointermove', onMove, \{ passive: false \}\)/);
|
||||
assert.match(js, /document\.addEventListener\('pointercancel', onEnd\)/);
|
||||
assert.match(js, /require\('\/models\/lib\/windowDrag'\)/);
|
||||
// negative: the two mouse-only drag handlers are gone, and no window drag listens for mousemove
|
||||
assert.doesNotMatch(js, /'mousedown \.js-card-drag-handle'/);
|
||||
assert.doesNotMatch(js, /'mousedown \.js-card-title-drag-handle'/);
|
||||
const helper = js.slice(js.indexOf('function beginCardWindowDrag'), js.indexOf('Template.cardDetails.events({'));
|
||||
assert.doesNotMatch(helper, /mousemove|mouseup|mousedown/);
|
||||
// a drag that began on a control swallows the click that follows it
|
||||
assert.match(helper, /addEventListener\('click', swallow, \{ capture: true, once: true \}\)/);
|
||||
// the window is clamped into reach, and the user-moved mark is kept (#6465)
|
||||
assert.match(helper, /clampWindow\(/);
|
||||
assert.match(helper, /markCardDetailsUserMoved\(\$card\)/);
|
||||
});
|
||||
|
||||
test('the drag areas are touch-action: none, the card number is over the title edit zone, popups too', () => {
|
||||
const css = read('client/components/cards/cardDetails.css');
|
||||
assert.match(css, /body\.desktop-mode \.card-details \.card-details-header,\nbody\.desktop-mode \.card-details \.card-details-header \.card-drag-handle \{\n touch-action: none;/);
|
||||
// the number above the zone (z-index 1)
|
||||
assert.match(css, /\.card-details-title-edit-zone \{[^}]*z-index: 1;/);
|
||||
assert.match(css, /\.card-details-header \.card-number \{[^}]*position: relative;\n z-index: 2;/);
|
||||
// negative: mobile mode (the full-screen card, which is not moved) is not made unscrollable
|
||||
assert.doesNotMatch(css, /body\.mobile-mode \.card-details[^{]*\{[^}]*touch-action: none/);
|
||||
const popup = read('client/components/main/popup.css');
|
||||
assert.match(popup, /\.pop-over \.header \{\n \/\*[\s\S]*?\*\/\n touch-action: none;/);
|
||||
assert.match(popup, /\.pop-over \.header \.popup-drag-handle \{\n touch-action: none;/);
|
||||
// popup.js moves a popup with Pointer Events already
|
||||
assert.match(read('client/components/main/popup.js'), /'pointerdown \.header'\(evt, tpl\)/);
|
||||
});
|
||||
Reference in new issue
Block a user