Skip to content

Commit f609874

Browse files
committed
✨ Added long-press to open the posts list context menu on touch devices
no ref - the posts list context menu (Duplicate, Feature, Delete, etc.) only opened via a right-click, which touch devices can't produce, so mobile users had no way to duplicate a post - a long-press now opens the same menu through the existing dropdown path; it's the natural touch equivalent of a right-click and there's no row text-selection it conflicts with - distinguishes a press from a scroll via a movement threshold, guards against the duplicate native contextmenu some touch platforms also emit, and suppresses the click that would otherwise navigate into the post - suppresses the iOS link-callout / selection magnifier on rows, scoped to touch devices
1 parent f651cc6 commit f609874

4 files changed

Lines changed: 166 additions & 12 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
<div role="menuitem" {{on "click" this.onClick capture=true}} {{on "mousedown" this.onMouseDown capture=true}} data-selected={{this.isSelected}} {{on "contextmenu" this.onContextMenu}} ...attributes>
1+
<div role="menuitem" {{on "click" this.onClick capture=true}} {{on "mousedown" this.onMouseDown capture=true}} data-selected={{this.isSelected}} {{on "contextmenu" this.onContextMenu}} {{on "touchstart" this.onTouchStart passive=true}} {{on "touchmove" this.onTouchMove passive=true}} {{on "touchend" this.onTouchEnd passive=true}} {{on "touchcancel" this.onTouchEnd passive=true}} ...attributes>
22
{{yield}}
33
</div>

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

Lines changed: 104 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import Component from '@glimmer/component';
22
import {action} from '@ember/object';
33
import {inject as service} from '@ember/service';
44

5+
// A touch long-press is the mobile equivalent of a right-click for opening the
6+
// context menu, since touch devices have no contextmenu gesture of their own.
7+
const LONG_PRESS_DURATION_MS = 500;
8+
const LONG_PRESS_MOVE_THRESHOLD_PX = 10;
9+
510
function clearTextSelection() {
611
if (window.getSelection) {
712
if (window.getSelection().empty) {
@@ -15,6 +20,11 @@ function clearTextSelection() {
1520
export default class ItemComponent extends Component {
1621
@service dropdown;
1722

23+
longPressTimer = null;
24+
longPressFired = false;
25+
touchStartX = 0;
26+
touchStartY = 0;
27+
1828
get selectionList() {
1929
return this.args.model;
2030
}
@@ -27,6 +37,29 @@ export default class ItemComponent extends Component {
2737
return this.selectionList.isSelected(this.id);
2838
}
2939

40+
willDestroy() {
41+
super.willDestroy(...arguments);
42+
this.cancelLongPress();
43+
}
44+
45+
cancelLongPress() {
46+
if (this.longPressTimer) {
47+
clearTimeout(this.longPressTimer);
48+
this.longPressTimer = null;
49+
}
50+
}
51+
52+
openContextMenu(x, y) {
53+
if (this.isSelected) {
54+
this.dropdown.toggleDropdown('context-menu', this, {left: x, top: y, selectionList: this.selectionList});
55+
} else {
56+
this.selectionList.clearSelection();
57+
this.selectionList.toggleItem(this.id);
58+
this.selectionList.clearOnNextUnfreeze();
59+
this.dropdown.toggleDropdown('context-menu', this, {left: x, top: y, selectionList: this.selectionList});
60+
}
61+
}
62+
3063
/**
3164
* We use the mouse down event because it allows us to cancel any text selection using preventDefault
3265
*/
@@ -64,6 +97,15 @@ export default class ItemComponent extends Component {
6497

6598
@action
6699
onClick(event) {
100+
// A long-press opens the context menu while the finger is still down; swallow
101+
// the click that fires on release so the row doesn't also navigate
102+
if (this.longPressFired) {
103+
this.longPressFired = false;
104+
event.preventDefault();
105+
event.stopPropagation();
106+
return;
107+
}
108+
67109
if (!this.selectionList.enabled) {
68110
return;
69111
}
@@ -96,19 +138,71 @@ export default class ItemComponent extends Component {
96138
return;
97139
}
98140

99-
let x = event.clientX;
100-
let y = event.clientY;
101-
102-
if (this.isSelected) {
103-
this.dropdown.toggleDropdown('context-menu', this, {left: x, top: y, selectionList: this.selectionList});
104-
} else {
105-
this.selectionList.clearSelection();
106-
this.selectionList.toggleItem(this.id);
107-
this.selectionList.clearOnNextUnfreeze();
108-
this.dropdown.toggleDropdown('context-menu', this, {left: x, top: y, selectionList: this.selectionList});
141+
// Some touch platforms (e.g. Android) emit a native contextmenu on long-press in
142+
// addition to our timer. If the long-press already opened the menu, just swallow
143+
// this event so we don't toggle it straight back closed.
144+
if (this.longPressFired) {
145+
event.preventDefault();
146+
event.stopPropagation();
147+
return;
109148
}
149+
this.cancelLongPress();
150+
151+
this.openContextMenu(event.clientX, event.clientY);
110152

111153
event.preventDefault();
112154
event.stopPropagation();
113155
}
156+
157+
@action
158+
onTouchStart(event) {
159+
if (!this.selectionList.enabled) {
160+
return;
161+
}
162+
163+
if (event.target.closest('[data-ignore-select]')) {
164+
return;
165+
}
166+
167+
this.cancelLongPress();
168+
this.longPressFired = false;
169+
170+
if (event.touches.length !== 1) {
171+
return;
172+
}
173+
174+
const touch = event.touches[0];
175+
this.touchStartX = touch.clientX;
176+
this.touchStartY = touch.clientY;
177+
178+
this.longPressTimer = setTimeout(() => {
179+
this.longPressTimer = null;
180+
this.longPressFired = true;
181+
this.openContextMenu(this.touchStartX, this.touchStartY);
182+
}, LONG_PRESS_DURATION_MS);
183+
}
184+
185+
@action
186+
onTouchMove(event) {
187+
if (!this.longPressTimer) {
188+
return;
189+
}
190+
191+
const touch = event.touches[0];
192+
if (!touch) {
193+
return;
194+
}
195+
196+
// A press that drifts past the threshold is a scroll, not a long-press
197+
const dx = Math.abs(touch.clientX - this.touchStartX);
198+
const dy = Math.abs(touch.clientY - this.touchStartY);
199+
if (dx > LONG_PRESS_MOVE_THRESHOLD_PX || dy > LONG_PRESS_MOVE_THRESHOLD_PX) {
200+
this.cancelLongPress();
201+
}
202+
}
203+
204+
@action
205+
onTouchEnd() {
206+
this.cancelLongPress();
207+
}
114208
}

ghost/admin/app/styles/layouts/content.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,15 @@
249249
border-color: transparent;
250250
}
251251

252+
/* On touch devices a long-press opens the context menu, so suppress the native
253+
link-callout and text-selection magnifier that would otherwise fire on the row */
254+
@media (hover: none) and (pointer: coarse) {
255+
.gh-posts-list-item-group {
256+
-webkit-touch-callout: none;
257+
user-select: none;
258+
}
259+
}
260+
252261
.posts-list[data-ctrl] .gh-posts-list-item-group:hover::before {
253262
opacity: 0.05;
254263
background: #6E78E5;

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

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import sinon from 'sinon';
33
import windowProxy from 'ghost-admin/utils/window-proxy';
44
import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support';
55
import {beforeEach, describe, it} from 'mocha';
6-
import {blur, click, currentURL, fillIn, find, findAll, triggerEvent, triggerKeyEvent, visit} from '@ember/test-helpers';
6+
import {blur, click, currentURL, fillIn, find, findAll, settled, triggerEvent, triggerKeyEvent, visit} from '@ember/test-helpers';
77
import {clickTrigger, selectChoose, selectSearch} from 'ember-power-select/test-support/helpers';
88
import {expect} from 'chai';
99
import {setupApplicationTest} from 'ember-mocha';
@@ -397,6 +397,57 @@ describe('Acceptance: Posts / Pages', function () {
397397
expect(lastRequest.url, 'request url').to.match(new RegExp(`/posts/${publishedPost.id}/copy/`));
398398
});
399399

400+
it('opens the context menu via long-press on touch devices', async function () {
401+
await visit('/posts');
402+
403+
const post = find(`[data-test-post-id="${publishedPost.id}"]`);
404+
expect(post, 'post').to.exist;
405+
406+
// touch devices have no contextmenu gesture, so a long-press opens the menu
407+
await triggerEvent(post, 'touchstart', {touches: [{clientX: 10, clientY: 10}]});
408+
409+
// wait for the long-press timer to fire, then let the menu render
410+
await new Promise((resolve) => {
411+
setTimeout(resolve, 700);
412+
});
413+
await settled();
414+
415+
const contextMenu = find('.gh-posts-context-menu');
416+
expect(contextMenu, 'context menu').to.exist;
417+
418+
const buttons = [...contextMenu.querySelectorAll('button')];
419+
const duplicate = buttons.find(button => button.innerText.trim().includes('Duplicate'));
420+
expect(duplicate, 'duplicate button').to.exist;
421+
422+
await click(duplicate);
423+
424+
const posts = findAll('[data-test-post-id]');
425+
expect(posts.length, 'all posts count').to.equal(5);
426+
const [lastRequest] = this.server.pretender.handledRequests.slice(-1);
427+
expect(lastRequest.url, 'request url').to.match(new RegExp(`/posts/${publishedPost.id}/copy/`));
428+
});
429+
430+
it('does not open the context menu when a touch becomes a scroll', async function () {
431+
await visit('/posts');
432+
433+
const post = find(`[data-test-post-id="${publishedPost.id}"]`);
434+
expect(post, 'post').to.exist;
435+
436+
await triggerEvent(post, 'touchstart', {touches: [{clientX: 10, clientY: 10}]});
437+
// a press that drifts past the threshold is a scroll and cancels the long-press
438+
await triggerEvent(post, 'touchmove', {touches: [{clientX: 10, clientY: 60}]});
439+
440+
await new Promise((resolve) => {
441+
setTimeout(resolve, 700);
442+
});
443+
await settled();
444+
445+
// the menu markup is always present; open-state lives on the container,
446+
// and the Duplicate action only renders once a row is selected
447+
expect(find('.gh-context-menu-container').getAttribute('data-open'), 'context menu open state').to.not.equal('true');
448+
expect(find('[data-test-post-context-menu] [data-test-button="duplicate"]'), 'duplicate button').to.not.exist;
449+
});
450+
400451
it('can copy a post link', async function () {
401452
sinon.stub(navigator.clipboard, 'writeText').resolves();
402453

0 commit comments

Comments
 (0)