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
2 changes: 1 addition & 1 deletion ghost/admin/app/components/multi-list/item.hbs
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
<div role="menuitem" {{on "click" this.onClick capture=true}} {{on "mousedown" this.onMouseDown capture=true}} data-selected={{this.isSelected}} {{on "contextmenu" this.onContextMenu}} ...attributes>
<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>
{{yield}}
</div>
187 changes: 177 additions & 10 deletions ghost/admin/app/components/multi-list/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ import Component from '@glimmer/component';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';

// A touch long-press is the mobile equivalent of a right-click for opening the
// context menu, since touch devices have no contextmenu gesture of their own.
const LONG_PRESS_DURATION_MS = 500;
const LONG_PRESS_MOVE_THRESHOLD_PX = 10;
// The compatibility "ghost" click fires at the release point; only swallow clicks
// within this radius of it so a real tap on a menu action is never discarded.
const GHOST_CLICK_RADIUS_PX = 20;

function clearTextSelection() {
if (window.getSelection) {
if (window.getSelection().empty) {
Expand All @@ -15,6 +23,14 @@ function clearTextSelection() {
export default class ItemComponent extends Component {
@service dropdown;

longPressTimer = null;
longPressFired = false;
touchStartX = 0;
touchStartY = 0;
ghostClickHandler = null;
ghostClickTimer = null;
nativeContextMenuHandler = null;

get selectionList() {
return this.args.model;
}
Expand All @@ -27,6 +43,85 @@ export default class ItemComponent extends Component {
return this.selectionList.isSelected(this.id);
}

willDestroy() {
super.willDestroy(...arguments);
this.cancelLongPress();
this.cancelGhostClickSuppression();
this.releaseNativeContextMenu();
}

cancelLongPress() {
if (this.longPressTimer) {
clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
}

// A long-press opens the menu while the finger is still down; on release the browser
// fires a synthetic "ghost" click that the dropdown service's body-click handler reads
// as an outside click, closing the menu instantly. Swallow that one click at the
// document level (capture phase, before it reaches the overlay or body) — but only when
// it lands at the release point, so a real tap on a menu action elsewhere is never
// discarded, even if the browser happens not to emit the ghost click.
suppressGhostClick() {
this.cancelGhostClickSuppression();
this.ghostClickHandler = (event) => {
const isGhostClick = Math.abs(event.clientX - this.touchStartX) <= GHOST_CLICK_RADIUS_PX
&& Math.abs(event.clientY - this.touchStartY) <= GHOST_CLICK_RADIUS_PX;
if (isGhostClick) {
event.preventDefault();
event.stopPropagation();
}
this.cancelGhostClickSuppression();
};
document.addEventListener('click', this.ghostClickHandler, true);
this.ghostClickTimer = setTimeout(() => this.cancelGhostClickSuppression(), 500);
}

cancelGhostClickSuppression() {
if (this.ghostClickHandler) {
document.removeEventListener('click', this.ghostClickHandler, true);
this.ghostClickHandler = null;
}
if (this.ghostClickTimer) {
clearTimeout(this.ghostClickTimer);
this.ghostClickTimer = null;
}
}

// While the finger is held, the browser's own long-press fires a native contextmenu
// event; if it lands on the menu's overlay, the overlay's onContextMenuOutside handler
// closes the menu mid-press. Eat contextmenu at the document level for the duration of
// the touch so our timer is the only thing that opens or holds the menu.
suppressNativeContextMenu() {
if (this.nativeContextMenuHandler) {
return;
}
this.nativeContextMenuHandler = (event) => {
event.preventDefault();
event.stopPropagation();
};
document.addEventListener('contextmenu', this.nativeContextMenuHandler, true);
}

releaseNativeContextMenu() {
if (this.nativeContextMenuHandler) {
document.removeEventListener('contextmenu', this.nativeContextMenuHandler, true);
this.nativeContextMenuHandler = null;
}
}

openContextMenu(x, y) {
if (this.isSelected) {
this.dropdown.toggleDropdown('context-menu', this, {left: x, top: y, selectionList: this.selectionList});
} else {
this.selectionList.clearSelection();
this.selectionList.toggleItem(this.id);
this.selectionList.clearOnNextUnfreeze();
this.dropdown.toggleDropdown('context-menu', this, {left: x, top: y, selectionList: this.selectionList});
}
}

/**
* We use the mouse down event because it allows us to cancel any text selection using preventDefault
*/
Expand Down Expand Up @@ -64,6 +159,15 @@ export default class ItemComponent extends Component {

@action
onClick(event) {
// A long-press opens the context menu while the finger is still down; swallow
// the click that fires on release so the row doesn't also navigate
if (this.longPressFired) {
this.longPressFired = false;
event.preventDefault();
event.stopPropagation();
return;
}

if (!this.selectionList.enabled) {
return;
}
Expand Down Expand Up @@ -96,19 +200,82 @@ export default class ItemComponent extends Component {
return;
}

let x = event.clientX;
let y = event.clientY;

if (this.isSelected) {
this.dropdown.toggleDropdown('context-menu', this, {left: x, top: y, selectionList: this.selectionList});
} else {
this.selectionList.clearSelection();
this.selectionList.toggleItem(this.id);
this.selectionList.clearOnNextUnfreeze();
this.dropdown.toggleDropdown('context-menu', this, {left: x, top: y, selectionList: this.selectionList});
// Some touch platforms (e.g. Android) emit a native contextmenu on long-press in
// addition to our timer. If the long-press already opened the menu, just swallow
// this event so we don't toggle it straight back closed.
if (this.longPressFired) {
event.preventDefault();
event.stopPropagation();
return;
}
this.cancelLongPress();

this.openContextMenu(event.clientX, event.clientY);

event.preventDefault();
event.stopPropagation();
}

@action
onTouchStart(event) {
if (!this.selectionList.enabled) {
return;
}

if (event.target.closest('[data-ignore-select]')) {
return;
}

this.cancelLongPress();
this.longPressFired = false;

if (event.touches.length !== 1) {
return;
}

const touch = event.touches[0];
this.touchStartX = touch.clientX;
this.touchStartY = touch.clientY;

this.suppressNativeContextMenu();

this.longPressTimer = setTimeout(() => {
this.longPressTimer = null;
this.longPressFired = true;
this.openContextMenu(this.touchStartX, this.touchStartY);
}, LONG_PRESS_DURATION_MS);
}

@action
onTouchMove(event) {
if (!this.longPressTimer) {
return;
}

const touch = event.touches[0];
if (!touch) {
return;
}

// A press that drifts past the threshold is a scroll, not a long-press
const dx = Math.abs(touch.clientX - this.touchStartX);
const dy = Math.abs(touch.clientY - this.touchStartY);
if (dx > LONG_PRESS_MOVE_THRESHOLD_PX || dy > LONG_PRESS_MOVE_THRESHOLD_PX) {
this.cancelLongPress();
}
}

@action
onTouchEnd() {
this.cancelLongPress();
this.releaseNativeContextMenu();

// If the long-press opened the menu, the finger lift fires a synthetic ghost click
// right after this; arm the one-shot suppressor now so that click can't close the menu.
// Arming here (at release) rather than when the menu opened means it works no matter
// how long the press was held.
if (this.longPressFired) {
this.suppressGhostClick();
}
}
}
4 changes: 2 additions & 2 deletions ghost/admin/app/components/posts-list/list-item-analytics.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
</div>
{{/unless}}
</div>
<div>
<div class="gh-post-list-title-content">
<h3 class="gh-content-entry-title">
{{@post.title}}
</h3>
Expand Down Expand Up @@ -66,7 +66,7 @@
</div>
{{/unless}}
</div>
<div>
<div class="gh-post-list-title-content">
<h3 class="gh-content-entry-title">
{{#if @post.featured}}
{{svg-jar "star-fill" class="gh-featured-post"}}
Expand Down
33 changes: 33 additions & 0 deletions ghost/admin/app/styles/layouts/content.css
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,15 @@
border-color: transparent;
}

/* On touch devices a long-press opens the context menu, so suppress the native
link-callout and text-selection magnifier that would otherwise fire on the row */
@media (hover: none) and (pointer: coarse) {
.gh-posts-list-item-group {
-webkit-touch-callout: none;
user-select: none;
}
}

.posts-list[data-ctrl] .gh-posts-list-item-group:hover::before {
opacity: 0.05;
background: #6E78E5;
Expand Down Expand Up @@ -782,6 +791,30 @@
.gh-post-list-author {
max-width: calc(100% - 140px);
}

/* the list is a CSS table, which sizes to its content and so grows past the
viewport on a phone; make it a normal block (the table columns are already
hidden at this width) so it tracks the viewport instead. Keep a min-width so
the text column stays usable on very narrow screens (the page scrolls a little
below ~350px rather than breaking titles mid-word) */
.posts-list.gh-list {
display: block;
min-width: 300px;
}

/* with the table gone, let the title/meta block shrink so a long title or tag
name can't hold the row open and push its action button off-screen */
.gh-post-list-title-content {
min-width: 0;
overflow: hidden;
}

/* wrap (and break long unbroken tokens like a numeric tag) rather than run off
the side, so nothing in the row can extend past the viewport */
.gh-post-list-title-content .gh-content-entry-title,
.gh-post-list-title-content .gh-content-entry-meta {
overflow-wrap: anywhere;
}
}

@media (max-width: 430px) {
Expand Down
7 changes: 7 additions & 0 deletions ghost/admin/app/styles/layouts/posts.css
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,13 @@
padding: 0;
overflow-x: auto;
overflow-y: visible;
/* the overlay scrollbar floats over the chips and can't be pushed below with
padding; hide it and let the row scroll by swipe/trackpad instead */
scrollbar-width: none;
}

.post-header .view-actions-bottom-row::-webkit-scrollbar {
display: none;
}

.post-header .view-actions::before {
Expand Down
Loading
Loading