Skip to content

Commit 7d84c1a

Browse files
committed
Improved long-press ghost-click suppression to spare real menu taps
no ref - the click suppressor armed after a long-press cancelled the next click anywhere within 500ms; if the browser never emitted the synthetic release click, a fast real tap on a menu action could be discarded (review feedback) - scope it to clicks at the touch point, where the compatibility click fires, so a real tap elsewhere always goes through even with the suppressor armed - add a regression test that taps a menu action after touchend with no ghost click emitted; this fails on the previous eat-any-click behaviour
1 parent 43f4720 commit 7d84c1a

2 files changed

Lines changed: 43 additions & 5 deletions

File tree

ghost/admin/app/components/multi-list/item.js

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import {inject as service} from '@ember/service';
66
// context menu, since touch devices have no contextmenu gesture of their own.
77
const LONG_PRESS_DURATION_MS = 500;
88
const LONG_PRESS_MOVE_THRESHOLD_PX = 10;
9+
// The compatibility "ghost" click fires at the release point; only swallow clicks
10+
// within this radius of it so a real tap on a menu action is never discarded.
11+
const GHOST_CLICK_RADIUS_PX = 20;
912

1013
function clearTextSelection() {
1114
if (window.getSelection) {
@@ -57,12 +60,18 @@ export default class ItemComponent extends Component {
5760
// A long-press opens the menu while the finger is still down; on release the browser
5861
// fires a synthetic "ghost" click that the dropdown service's body-click handler reads
5962
// as an outside click, closing the menu instantly. Swallow that one click at the
60-
// document level (capture phase, before it reaches the overlay or body).
63+
// document level (capture phase, before it reaches the overlay or body) — but only when
64+
// it lands at the release point, so a real tap on a menu action elsewhere is never
65+
// discarded, even if the browser happens not to emit the ghost click.
6166
suppressGhostClick() {
6267
this.cancelGhostClickSuppression();
6368
this.ghostClickHandler = (event) => {
64-
event.preventDefault();
65-
event.stopPropagation();
69+
const isGhostClick = Math.abs(event.clientX - this.touchStartX) <= GHOST_CLICK_RADIUS_PX
70+
&& Math.abs(event.clientY - this.touchStartY) <= GHOST_CLICK_RADIUS_PX;
71+
if (isGhostClick) {
72+
event.preventDefault();
73+
event.stopPropagation();
74+
}
6675
this.cancelGhostClickSuppression();
6776
};
6877
document.addEventListener('click', this.ghostClickHandler, true);

ghost/admin/tests/acceptance/content-test.js

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -418,9 +418,9 @@ describe('Acceptance: Posts / Pages', function () {
418418
await triggerEvent(find('.gh-context-menu-overlay'), 'contextmenu');
419419
expect(find('.gh-posts-context-menu'), 'context menu stays visible while held').to.be.visible;
420420

421-
// releasing the long-press fires a synthetic click; it must not close the menu either
421+
// releasing the long-press fires a synthetic click at the touch point; it must not close the menu
422422
await triggerEvent(post, 'touchend');
423-
await triggerEvent(find('.gh-context-menu-overlay'), 'click');
423+
await triggerEvent(find('.gh-context-menu-overlay'), 'click', {clientX: 10, clientY: 10});
424424
expect(find('.gh-posts-context-menu'), 'context menu stays visible after release click').to.be.visible;
425425

426426
const contextMenu = find('.gh-posts-context-menu');
@@ -436,6 +436,35 @@ describe('Acceptance: Posts / Pages', function () {
436436
expect(lastRequest.url, 'request url').to.match(new RegExp(`/posts/${publishedPost.id}/copy/`));
437437
});
438438

439+
it('does not discard a real tap on a menu action after a long-press', async function () {
440+
await visit('/posts');
441+
442+
const post = find(`[data-test-post-id="${publishedPost.id}"]`);
443+
expect(post, 'post').to.exist;
444+
445+
await triggerEvent(post, 'touchstart', {touches: [{clientX: 10, clientY: 10}]});
446+
await new Promise((resolve) => {
447+
setTimeout(resolve, 700);
448+
});
449+
await settled();
450+
// arm the ghost-click suppressor, but emit no synthetic release click
451+
await triggerEvent(post, 'touchend');
452+
453+
// a real tap on a menu action (away from the touch point) must still go through,
454+
// even though the suppressor is armed and no ghost click was consumed
455+
const contextMenu = find('.gh-posts-context-menu');
456+
const buttons = [...contextMenu.querySelectorAll('button')];
457+
const duplicate = buttons.find(button => button.innerText.trim().includes('Duplicate'));
458+
expect(duplicate, 'duplicate button').to.exist;
459+
460+
await click(duplicate);
461+
462+
const posts = findAll('[data-test-post-id]');
463+
expect(posts.length, 'all posts count').to.equal(5);
464+
const [lastRequest] = this.server.pretender.handledRequests.slice(-1);
465+
expect(lastRequest.url, 'request url').to.match(new RegExp(`/posts/${publishedPost.id}/copy/`));
466+
});
467+
439468
it('does not open the context menu when a touch becomes a scroll', async function () {
440469
await visit('/posts');
441470

0 commit comments

Comments
 (0)