From 8c046740f08a8d4d477c64d4c531610732cad452 Mon Sep 17 00:00:00 2001 From: Simon Backx Date: Tue, 4 Apr 2023 14:43:43 +0200 Subject: [PATCH] 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. --- ghost/admin/.lint-todo | 2 + .../admin/app/components/gh-context-menu.hbs | 6 + ghost/admin/app/components/gh-context-menu.js | 77 ++++++++++ .../admin/app/components/multi-list/item.hbs | 3 + ghost/admin/app/components/multi-list/item.js | 71 ++++++++++ .../admin/app/components/multi-list/list.hbs | 10 ++ ghost/admin/app/components/multi-list/list.js | 84 +++++++++++ .../admin/app/components/posts-list/list.hbs | 51 +++++++ ghost/admin/app/components/posts-list/list.js | 12 ++ ghost/admin/app/controllers/posts.js | 2 + ghost/admin/app/services/dropdown.js | 4 +- .../admin/app/styles/components/dropdowns.css | 33 +++++ ghost/admin/app/styles/layouts/content.css | 49 +++++++ ghost/admin/app/templates/posts.hbs | 57 ++++---- ghost/admin/app/utils/selection-list.js | 132 ++++++++++++++++++ 15 files changed, 566 insertions(+), 27 deletions(-) create mode 100644 ghost/admin/app/components/gh-context-menu.hbs create mode 100644 ghost/admin/app/components/gh-context-menu.js create mode 100644 ghost/admin/app/components/multi-list/item.hbs create mode 100644 ghost/admin/app/components/multi-list/item.js create mode 100644 ghost/admin/app/components/multi-list/list.hbs create mode 100644 ghost/admin/app/components/multi-list/list.js create mode 100644 ghost/admin/app/components/posts-list/list.hbs create mode 100644 ghost/admin/app/components/posts-list/list.js create mode 100644 ghost/admin/app/utils/selection-list.js diff --git a/ghost/admin/.lint-todo b/ghost/admin/.lint-todo index 8bc6eeff5f..e30997a622 100644 --- a/ghost/admin/.lint-todo +++ b/ghost/admin/.lint-todo @@ -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 diff --git a/ghost/admin/app/components/gh-context-menu.hbs b/ghost/admin/app/components/gh-context-menu.hbs new file mode 100644 index 0000000000..cc3ce661fb --- /dev/null +++ b/ghost/admin/app/components/gh-context-menu.hbs @@ -0,0 +1,6 @@ + diff --git a/ghost/admin/app/components/gh-context-menu.js b/ghost/admin/app/components/gh-context-menu.js new file mode 100644 index 0000000000..e608ec7c42 --- /dev/null +++ b/ghost/admin/app/components/gh-context-menu.js @@ -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(); + } +} diff --git a/ghost/admin/app/components/multi-list/item.hbs b/ghost/admin/app/components/multi-list/item.hbs new file mode 100644 index 0000000000..2298138bd9 --- /dev/null +++ b/ghost/admin/app/components/multi-list/item.hbs @@ -0,0 +1,3 @@ +
+ {{yield}} +
diff --git a/ghost/admin/app/components/multi-list/item.js b/ghost/admin/app/components/multi-list/item.js new file mode 100644 index 0000000000..fc68044963 --- /dev/null +++ b/ghost/admin/app/components/multi-list/item.js @@ -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(); + } +} diff --git a/ghost/admin/app/components/multi-list/list.hbs b/ghost/admin/app/components/multi-list/list.hbs new file mode 100644 index 0000000000..7d5bb04cee --- /dev/null +++ b/ghost/admin/app/components/multi-list/list.hbs @@ -0,0 +1,10 @@ +
+ {{yield + (hash + item=( + component "multi-list/item" + model=@model + ) + ) + }} +
diff --git a/ghost/admin/app/components/multi-list/list.js b/ghost/admin/app/components/multi-list/list.js new file mode 100644 index 0000000000..3099a6aa17 --- /dev/null +++ b/ghost/admin/app/components/multi-list/list.js @@ -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; + } + } +} diff --git a/ghost/admin/app/components/posts-list/list.hbs b/ghost/admin/app/components/posts-list/list.hbs new file mode 100644 index 0000000000..d169c3b0fb --- /dev/null +++ b/ghost/admin/app/components/posts-list/list.hbs @@ -0,0 +1,51 @@ + + {{#each @model as |post|}} + + + + {{/each}} + + +{{!-- The currently selected item or items are passed to the context menu --}} + + + diff --git a/ghost/admin/app/components/posts-list/list.js b/ghost/admin/app/components/posts-list/list.js new file mode 100644 index 0000000000..8640f533e1 --- /dev/null +++ b/ghost/admin/app/components/posts-list/list.js @@ -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(); + } +} diff --git a/ghost/admin/app/controllers/posts.js b/ghost/admin/app/controllers/posts.js index b6e927645a..cb0e2de5e1 100644 --- a/ghost/admin/app/controllers/posts.js +++ b/ghost/admin/app/controllers/posts.js @@ -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; diff --git a/ghost/admin/app/services/dropdown.js b/ghost/admin/app/services/dropdown.js index b47860c658..e8b5122873 100644 --- a/ghost/admin/app/services/dropdown.js +++ b/ghost/admin/app/services/dropdown.js @@ -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}); } } diff --git a/ghost/admin/app/styles/components/dropdowns.css b/ghost/admin/app/styles/components/dropdowns.css index 87c25c646f..1547765aa8 100644 --- a/ghost/admin/app/styles/components/dropdowns.css +++ b/ghost/admin/app/styles/components/dropdowns.css @@ -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; +} diff --git a/ghost/admin/app/styles/layouts/content.css b/ghost/admin/app/styles/layouts/content.css index c1418edd65..6eae636317 100644 --- a/ghost/admin/app/styles/layouts/content.css +++ b/ghost/admin/app/styles/layouts/content.css @@ -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; } diff --git a/ghost/admin/app/templates/posts.hbs b/ghost/admin/app/templates/posts.hbs index 67c1fe7ad7..fcbaf22dc3 100644 --- a/ghost/admin/app/templates/posts.hbs +++ b/ghost/admin/app/templates/posts.hbs @@ -29,32 +29,39 @@
-
    + {{#if (feature "makingItRain")}} + + {{else}} +
      - {{#each this.postsInfinityModel as |post|}} - - {{else}} -
    1. -
      - {{#if this.showingAll}} - {{svg-jar "posts-placeholder" class="gh-posts-placeholder"}} -

      Start creating content.

      - - Write a new post - - {{else}} -

      No posts match the current filter

      - - Show all posts - - {{/if}} -
      -
    2. - {{/each}} -
    + {{#each this.postsInfinityModel as |post|}} + + {{else}} +
  1. +
    + {{#if this.showingAll}} + {{svg-jar "posts-placeholder" class="gh-posts-placeholder"}} +

    Start creating content.

    + + Write a new post + + {{else}} +

    No posts match the current filter

    + + Show all posts + + {{/if}} +
    +
  2. + {{/each}} +
+ {{/if}}