Added support for selecting posts

refs https://github.com/TryGhost/Team/issues/2906

Adds a way to select posts using CMD, shift and CMD+A. And adds a placeholder context menu.

Behind the making it rain feature flag.
This commit is contained in:
Simon Backx 2023-04-04 14:43:43 +02:00 committed by Simon Backx
parent aa5272ffb9
commit 8c046740f0
15 changed files with 566 additions and 27 deletions

View File

@ -562,3 +562,5 @@ add|ember-template-lint|require-input-label|10|12|10|12|8c3c0ea315ff4da828363989
add|ember-template-lint|no-action|465|46|465|46|f2f0f3f512f141fdd821333c873f5052813bb491|1677974400000|1688342400000|1693526400000|app/components/gh-portal-links.hbs
add|ember-template-lint|no-action|271|58|271|58|5124558b018d5e90a3d203fd54c5e4ca8e9b0548|1680566400000|1690934400000|1696118400000|app/components/modal-portal-settings.hbs
add|ember-template-lint|no-action|289|68|289|68|eaa96ff81a7c4b4743ca191655c017bd90549e96|1680566400000|1690934400000|1696118400000|app/components/modal-portal-settings.hbs
add|ember-template-lint|no-invalid-interactive|1|103|1|103|f5a46b2538fbf79a40f2683ff1151ca60e0fa0ca|1680652800000|1691020800000|1696204800000|app/components/gh-context-menu.hbs
add|ember-template-lint|no-invalid-interactive|5|53|5|53|9647ef6afba919b2af04fe551b0fdf0fb63be849|1680652800000|1691020800000|1696204800000|app/components/gh-context-menu.hbs

View File

@ -0,0 +1,6 @@
<div role="menu" class="gh-context-menu-container" {{did-insert this.setup}} data-open={{this.isOpen}} {{on "click" this.stopClicks}}>
<div class="gh-context-menu" style={{this.style}}>
{{yield this this.selectionList}}
</div>
<div role="none" class="gh-context-menu-overlay" {{on "click" this.close}} {{on "contextmenu" this.onContextMenuOutside}}></div>
</div>

View File

@ -0,0 +1,77 @@
import Component from '@glimmer/component';
import SelectionList from '../utils/selection-list';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
import {tracked} from '@glimmer/tracking';
export default class GhContextMenu extends Component {
@service dropdown;
@tracked isOpen = false;
@tracked left = 0;
@tracked top = 0;
@tracked selectionList = new SelectionList();
get name() {
return this.args.name;
}
get style() {
return `left: ${this.left}px; top: ${this.top}px;`;
}
@action
setup() {
const dropdownService = this.dropdown;
dropdownService.on('close', this, this.close);
dropdownService.on('toggle', this, this.toggle);
}
willDestroy() {
super.willDestroy(...arguments);
const dropdownService = this.dropdown;
dropdownService.off('close', this, this.close);
dropdownService.off('toggle', this, this.toggle);
}
@action
open() {
this.isOpen = true;
}
@action
close() {
this.isOpen = false;
}
@action
onContextMenuOutside(event) {
this.close();
event.preventDefault();
event.stopPropagation();
}
// Called by the dropdown service when the context menu should open
@action
toggle(options) {
const targetDropdownName = options.target;
if (this.name === targetDropdownName) {
if (options.left !== undefined) {
this.left = options.left;
this.top = options.top;
}
if (options.selectionList) {
this.selectionList = options.selectionList;
}
this.open();
} else if (this.isOpen) {
this.close();
}
}
@action
stopClicks(event) {
event.stopPropagation();
}
}

View File

@ -0,0 +1,3 @@
<div role="menuitem" {{on "click" this.onClick capture=true}} data-selected={{this.isSelected}} {{on "contextmenu" this.onContextMenu}} ...attributes>
{{yield}}
</div>

View File

@ -0,0 +1,71 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
function clearTextSelection() {
if (window.getSelection) {
if (window.getSelection().empty) { // Chrome
window.getSelection().empty();
} else if (window.getSelection().removeAllRanges) { // Firefox
window.getSelection().removeAllRanges();
}
} else if (document.selection) { // IE?
document.selection.empty();
}
}
export default class ItemComponent extends Component {
@service dropdown;
get selectionList() {
return this.args.model;
}
get id() {
return this.args.id;
}
get isSelected() {
return this.selectionList.isSelected(this.id);
}
@action
onClick(event) {
const shiftKey = event.shiftKey;
const ctrlKey = event.ctrlKey || event.metaKey;
if (ctrlKey) {
this.selectionList.toggleItem(this.id);
event.preventDefault();
event.stopPropagation();
clearTextSelection();
} else if (shiftKey) {
try {
this.selectionList.shiftItem(this.id);
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
}
event.preventDefault();
event.stopPropagation();
clearTextSelection();
}
}
@action
onContextMenu(event) {
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 {
const selectionList = this.selectionList.cloneEmpty();
selectionList.toggleItem(this.id);
this.dropdown.toggleDropdown('context-menu', this, {left: x, top: y, selectionList});
}
event.preventDefault();
event.stopPropagation();
}
}

View File

@ -0,0 +1,10 @@
<div data-ctrl={{this.actionKeyPressed}} {{did-insert this.setup}} ...attributes>
{{yield
(hash
item=(
component "multi-list/item"
model=@model
)
)
}}
</div>

View File

@ -0,0 +1,84 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {tracked} from '@glimmer/tracking';
export default class ListComponent extends Component {
@tracked ctrlPressed = false;
@tracked metaPressed = false;
@tracked shiftPressed = false;
get selectionList() {
return this.args.model;
}
/**
* Required for shift behaviour
*/
get allIds() {
return this.args.all.map(a => a.id);
}
get actionKeyPressed() {
return this.ctrlPressed || this.metaPressed || this.shiftPressed;
}
willDestroy() {
super.willDestroy(...arguments);
window.removeEventListener('keydown', this.onKeyDow, {passive: true});
window.removeEventListener('keyup', this.onKeyUp, {passive: true});
window.removeEventListener('click', this.onWindowClicked, {passive: true});
}
@action
setup() {
window.addEventListener('keydown', this.onKeyDown, {passive: false});
window.addEventListener('keyup', this.onKeyUp, {passive: true});
window.addEventListener('click', this.onWindowClicked, {passive: true});
}
@action
onWindowClicked(event) {
// Clear selection if no ctrl/meta key is pressed
if (!event.metaKey && !event.ctrlKey) {
this.selectionList.clearSelection();
}
}
@action
onKeyDown(event) {
if (event.key === 'Control') {
this.ctrlPressed = true;
}
if (event.key === 'Meta') {
this.metaPressed = true;
}
if (event.key === 'Shift') {
this.shiftPressed = true;
}
if ((event.ctrlKey || event.metaKey) && !event.shiftKey) {
if (event.key === 'a') {
this.selectionList.selectAll();
event.preventDefault();
return;
}
}
if (event.key === 'Escape') {
this.selectionList.clearSelection();
}
}
@action
onKeyUp(event) {
if (event.key === 'Control') {
this.ctrlPressed = false;
}
if (event.key === 'Meta') {
this.metaPressed = false;
}
if (event.key === 'Shift') {
this.shiftPressed = false;
}
}
}

View File

@ -0,0 +1,51 @@
<MultiList::List @model={{@list}} class="posts-list gh-list {{unless @model "no-posts"}} feature-memberAttribution" as |list| >
{{#each @model as |post|}}
<list.item @id={{post.id}} class="gh-posts-list-item-group">
<PostsList::ListItem
@post={{post}}
data-test-post-id={{post.id}}
/>
</list.item>
{{/each}}
</MultiList::List>
{{!-- The currently selected item or items are passed to the context menu --}}
<GhContextMenu
@name="context-menu"
as |menu selectionList|
>
<ul class="gh-posts-context-menu dropdown-menu dropdown-triangle-top-left">
{{#if selectionList.isSingle}}
<li>
<button class="mr2" type="button" disabled {{on "click" menu.close}}>
<span>Duplicate</span>
</button>
</li>
{{/if}}
<li>
<button class="mr2" type="button" disabled {{on "click" menu.close}}>
<span>Unpublish</span>
</button>
</li>
<li>
<button class="mr2" type="button" disabled {{on "click" menu.close}}>
<span>Feature</span>
</button>
</li>
<li>
<button class="mr2" type="button" disabled {{on "click" menu.close}}>
<span>Add tag...</span>
</button>
</li>
<li>
<button class="mr2" type="button" disabled {{on "click" menu.close}}>
<span>Post access...</span>
</button>
</li>
<li>
<button class="mr2" type="button" {{on "click" (fn this.deletePosts menu)}}>
<span>Delete</span>
</button>
</li>
</ul>
</GhContextMenu>

View File

@ -0,0 +1,12 @@
import Component from '@glimmer/component';
export default class PostsList extends Component {
get list() {
return this.args.list;
}
deletePosts(menu) {
alert('Deleting posts not yet supported.');
menu.close();
}
}

View File

@ -1,4 +1,5 @@
import Controller from '@ember/controller';
import SelectionList from 'ghost-admin/utils/selection-list';
import {DEFAULT_QUERY_PARAMS} from 'ghost-admin/helpers/reset-query-params';
import {action} from '@ember/object';
import {inject} from 'ghost-admin/decorators/inject';
@ -63,6 +64,7 @@ export default class PostsController extends Controller {
@tracked author = null;
@tracked tag = null;
@tracked order = null;
@tracked selectionList = new SelectionList(this.postsInfinityModel);
availableTypes = TYPES;
availableVisibilities = VISIBILITIES;

View File

@ -22,7 +22,7 @@ export default class DropdownService extends Service.extend(Evented, BodyEventLi
}
@action
toggleDropdown(dropdownName, dropdownButton) {
this.trigger('toggle', {target: dropdownName, button: dropdownButton});
toggleDropdown(dropdownName, dropdownButton, options = {}) {
this.trigger('toggle', {target: dropdownName, button: dropdownButton, ...options});
}
}

View File

@ -72,6 +72,10 @@
transition: none;
}
.dropdown-menu li > button:disabled {
opacity: 0.4;
}
.dropdown-menu svg {
margin-right: 10px;
height: 14px;
@ -314,3 +318,32 @@
line-height: 1em;
fill: none;
}
/**
Post context menu
*/
.gh-context-menu-container {
visibility: hidden;
}
/* Disable user interaction with a div that covers the full view. We don't use a javascript based approach here, because you want to close a context menu even when you click a link on the page. With js, we cannot ignore or block that link click without complex event listeners. */
.gh-context-menu-overlay {
position: fixed;
content: '';
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 1000;
}
.gh-context-menu-container[data-open] {
visibility: visible;
}
.gh-context-menu {
position: fixed;
max-width: 200px;
z-index: 1001;
}

View File

@ -153,6 +153,55 @@
position: relative;
}
/* START Temporary styles to move post list to use flex instead of tables */
.gh-posts-list-item-group {
padding: 0 var(--main-layout-content-sidepadding);
margin: 0 calc(var(--main-layout-content-sidepadding) * -1 + 10px);
border-radius: 5px;
}
.gh-posts-list-item-group .gh-list-row {
display: flex;
align-items: stretch;
justify-content: space-between;
}
.gh-posts-list-item-group .gh-list-row .gh-list-data {
display: block;
min-width: 0;
}
.gh-posts-list-item-group .gh-list-row .gh-list-data:first-child {
flex-grow: 1;
}
.posts-list[data-ctrl] {
cursor: default !important; /* Hide pointer */
}
.posts-list[data-ctrl] .gh-posts-list-item-group * {
cursor: default !important; /* Hide pointer */
pointer-events: none;
}
.posts-list .gh-posts-list-item-group:hover {
background: #fafafb;
}
.posts-list[data-ctrl] .gh-posts-list-item-group:hover {
background: #f3f3f9;
}
.gh-posts-list-item-group[data-selected] {
background: #eae5ff !important;
}
.gh-posts-list-item-group .gh-posts-list-item:hover .gh-list-data {
background: transparent !important;
}
/* END Temporary styles to move post list to use flex instead of tables */
.gh-posts-list-item {
position: relative;
}

View File

@ -29,32 +29,39 @@
</GhCanvasHeader>
<section class="view-container content-list">
<ol class="posts-list gh-list {{unless this.postsInfinityModel "no-posts"}} feature-memberAttribution">
{{#if (feature "makingItRain")}}
<PostsList::List
@model={{this.postsInfinityModel}}
@list={{this.selectionList}}
/>
{{else}}
<ol class="posts-list gh-list {{unless this.postsInfinityModel "no-posts"}} feature-memberAttribution">
{{#each this.postsInfinityModel as |post|}}
<PostsList::ListItem
@post={{post}}
data-test-post-id={{post.id}}
/>
{{else}}
<li class="no-posts-box" data-test-no-posts-box>
<div class="no-posts">
{{#if this.showingAll}}
{{svg-jar "posts-placeholder" class="gh-posts-placeholder"}}
<h4>Start creating content.</h4>
<LinkTo @route="editor.new" @model="post" class="gh-btn gh-btn-green" data-test-link="write-a-new-post">
<span>Write a new post</span>
</LinkTo>
{{else}}
<h4>No posts match the current filter</h4>
<LinkTo @route="posts" @query={{hash type=null author=null tag=null}} class="gh-btn" data-test-link="show-all">
<span>Show all posts</span>
</LinkTo>
{{/if}}
</div>
</li>
{{/each}}
</ol>
{{#each this.postsInfinityModel as |post|}}
<PostsList::ListItem
@post={{post}}
data-test-post-id={{post.id}}
/>
{{else}}
<li class="no-posts-box" data-test-no-posts-box>
<div class="no-posts">
{{#if this.showingAll}}
{{svg-jar "posts-placeholder" class="gh-posts-placeholder"}}
<h4>Start creating content.</h4>
<LinkTo @route="editor.new" @model="post" class="gh-btn gh-btn-green" data-test-link="write-a-new-post">
<span>Write a new post</span>
</LinkTo>
{{else}}
<h4>No posts match the current filter</h4>
<LinkTo @route="posts" @query={{hash type=null author=null tag=null}} class="gh-btn" data-test-link="show-all">
<span>Show all posts</span>
</LinkTo>
{{/if}}
</div>
</li>
{{/each}}
</ol>
{{/if}}
<GhInfinityLoader
@infinityModel={{this.postsInfinityModel}}

View File

@ -0,0 +1,132 @@
import {tracked} from '@glimmer/tracking';
export default class SelectionList {
@tracked selectedIds = new Set();
@tracked inverted = false;
@tracked lastSelectedId = null;
@tracked lastShiftSelectionGroup = new Set();
infinityModel;
constructor(infinityModel) {
this.infinityModel = infinityModel ?? {content: []};
}
/**
* Create an empty copy
*/
cloneEmpty() {
return new SelectionList(this.infinityModel);
}
/**
* Return a list of models that are already loaded in memory.
* Keep in mind that when using CMD + A, we don't have all items in memory!
*/
get availableModels() {
const arr = [];
for (const item of this.infinityModel.content) {
if (this.isSelected(item.id)) {
arr.push(item);
}
}
return arr;
}
get isSingle() {
return this.selectedIds.size === 1 && !this.inverted;
}
isSelected(id) {
if (this.inverted) {
return !this.selectedIds.has(id);
}
return this.selectedIds.has(id);
}
toggleItem(id) {
this.lastShiftSelectionGroup = new Set();
this.lastSelectedId = id;
if (this.selectedIds.has(id)) {
this.selectedIds.delete(id);
} else {
this.selectedIds.add(id);
}
// Force update
// eslint-disable-next-line no-self-assign
this.selectedIds = this.selectedIds;
}
/**
* Select all items between the last selection or the first one if none
*/
shiftItem(id) {
// Unselect last selected items
for (const item of this.lastShiftSelectionGroup) {
if (this.inverted) {
this.selectedIds.add(item);
} else {
this.selectedIds.delete(item);
}
}
this.lastShiftSelectionGroup = new Set();
// todo
let running = false;
if (this.lastSelectedId === null) {
running = true;
}
for (const item of this.infinityModel.content) {
// Exlusing the last selected item
if (item.id === this.lastSelectedId || item.id === id) {
if (!running) {
running = true;
// Skip last selected on its own
if (item.id === this.lastSelectedId) {
continue;
}
} else {
// Still include id
if (item.id === id) {
this.lastShiftSelectionGroup.add(item.id);
if (this.inverted) {
this.selectedIds.delete(item.id);
} else {
this.selectedIds.add(item.id);
}
}
break;
}
}
if (running) {
this.lastShiftSelectionGroup.add(item.id);
if (this.inverted) {
this.selectedIds.delete(item.id);
} else {
this.selectedIds.add(item.id);
}
}
}
// Force update
// eslint-disable-next-line no-self-assign
this.selectedIds = this.selectedIds;
}
selectAll() {
this.selectedIds = new Set();
this.inverted = !this.inverted;
}
clearSelection() {
this.selectedIds = new Set();
this.inverted = false;
}
}