0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-02-24 23:48:13 -05:00

improved card selection behaviour (#608)

Refs: https://github.com/TryGhost/Ghost/issues/8191
Refs: https://github.com/TryGhost/Ghost/issues/8194

Changes the selection behaviour of mobiledoc-cards:
If you navigate to a card with a keyboard or click on the new editor toolbar it "hard selects".
If you click into the body of a card to edit it it "soft selects".

When a card is "hard selected" you can navigate out of the card and to the previous or following blocks within the mobiledoc with the keyboard, you can delete the current card with the backspace or delete button, and you can create a new block following the card with the enter key.

When a card is soft selected it is simply displayed as selected and allows the user to edit content within the card.

New card toolbar:
Allows a user to delete the card, save the card, and "hard select" a card.

New title behaviour:
Pressing the enter key within the title "splits" the title at the cursor point, if multiple characters are selected they are first deleted, and creates a new paragraph at the top of the document with the trailing characters after the split.

gh-cm-editor updates:
Adds an on-focus event to gh-cm-editor
This commit is contained in:
Ryan McCarvill 2017-03-31 03:29:08 +13:00 committed by Kevin Ansfield
parent 7f44e0128e
commit 5724a94fbc
17 changed files with 394 additions and 102 deletions

View file

@ -45,7 +45,13 @@ const CmEditorComponent = Component.extend(InvokeActionMixin, {
editor.getDoc().setValue(this.get('_value'));
// events
editor.on('focus', bind(this, 'set', 'isFocused', true));
editor.on('focus', () => {
run(this, function() {
this.set('isFocused', true);
this.invokeAction('focus-in', editor.getDoc().getValue());
});
});
editor.on('blur', bind(this, 'set', 'isFocused', false));
editor.on('change', () => {
run(this, function () {

View file

@ -30,6 +30,37 @@ export default Component.extend({
}
if (event.keyCode === 13) {
// enter
// on enter we want to split the title, create a new paragraph in the mobile doc and insert it into the content.
let {editor} = window;
let title = this.$('.gh-editor-title');
editor.run((postEditor) => {
let {anchorOffset, focusOffset} = window.getSelection();
let text = title.text();
let startText = ''; // the text before the split
let endText = ''; // the text after the split
// if the selection is not collapsed then we have to delete the text that is selected.
if (anchorOffset !== focusOffset) {
// if the start of the selection is after the end then reverse the selection
if (anchorOffset > focusOffset) {
[anchorOffset, focusOffset] = [focusOffset, anchorOffset];
}
startText = text.substring(0, anchorOffset);
endText = text.substring(focusOffset);
} else {
startText = text.substring(0, anchorOffset);
endText = text.substring(anchorOffset);
}
title.html(startText);
let marker = editor.builder.createMarker(endText);
let newSection = editor.builder.createMarkupSection('p', [marker]);
postEditor.insertSectionBefore(editor.post.sections, newSection, editor.post.sections.head);
let range = newSection.toRange();
range.tail.offset = 0; // colapse range
postEditor.setRange(range);
});
return false;
}

View file

@ -23,59 +23,131 @@
letter-spacing: 0.1px;
}
.dropper-bottom {
border-bottom: 66px solid #5ba4e5;
}
.dropper-top {
border-top: 66px solid #5ba4e5;
}
.dropper-left {
border-left: 66px solid #5ba4e5;
}
.dropper-right {
border-right: 66px solid #5ba4e5;
}
.__mobiledoc-editor div {
}
.kg-card {
position: relative;
display: block; /* required for cursor movement around card */
width: 100%;
display: block;
padding: 10px;
outline:none;
}
.kg-card:hover {
.kg-card:hover, .kg-card.selected {
box-shadow: var(--blue) 0 0 0 1px;
border-radius:10px;
}
.kg-card .card-handle {
.kg-card.selected-hard {
box-shadow: var(--blue) 0 0 0 3px;
}
.kg-card .kg-card-toolbar {
position: absolute;
right: 0px;
left: 0px;
top: 0px;
margin-top: -25px;
height: 20px;
margin-top: -56px;
height: 46px;
width:100%;
display: none;
}
.button-group {
color: color(var(--lightgrey) l(-10%));
background: linear-gradient(
color(var(--darkgrey) l(-3%)),
color(var(--darkgrey) l(-8%))
);
border-top-left-radius: 5px;
border-top-right-radius: 5px;
border-radius:5px;
height:46px;
display:flex;
box-shadow: 0 0 0 1px color(var(--darkgrey) l(-10%)), 0 8px 16px rgba(26,39,49,0.16), rgba(255,255,255,0.09) 0 1px 0 0 inset;
}
.kg-card:hover .card-handle {
/* keeps the hover when the cursor is moving from the card to the toolbar */
.button-group:before {
display: block;
content: "";
position: absolute;
bottom:-9px;
width: 100%;
height: 8px;
}
.kg-card .card-handle label {
font-size: 10px;
.button-group:after {
display: block;
content: "";
position: absolute;
bottom:-9px;
left: 50%;
margin-left: -10px;
width: 0;
height: 0;
border-left: transparent 10px solid;
border-right: transparent 10px solid;
border-top: color(var(--darkgrey) l(-10%)) 8px solid;
}
.kg-card .card-handle button {
background-color: var(--lightgrey);
border: 1px solid var(--midgrey);
font-size: 10px;
min-width: 80px;
.kg-card:hover .kg-card-toolbar, .kg-card.selected .kg-card-toolbar {
display: flex;
align-items: stretch;
justify-content: space-around;
flex-flow: row wrap;
}
.kg-card .kg-card-toolbar label {
flex-grow: 1;
font-size: 1.3rem;
line-height: 46px;
text-align:center;
vertical-align: middle;
font-weight: bold;
padding:0 18px;
}
.kg-card .kg-card-toolbar button {
display: flex;
justify-content: center;
align-items: center;
height: 46px;
min-width: 32px;
font-size: 1.6rem;
line-height: 30px;
padding:0 5px 0 5px;
transition: text-shadow 0.3s ease;
}
.kg-card .kg-card-toolbar button.kg-card-button-text {
font-size: 1.3rem;
line-height: 46px;
width:70px;
text-align:center;
vertical-align: middle;
padding:0;
}
.kg-card .kg-card-toolbar button.kg-card-button-save {
font-size: 1.3rem;
line-height: 30px;
height:30px;
width:60px;
text-align:center;
vertical-align: middle;
margin:8px;
color: #fff;
text-shadow: 0 -1px 0 rgba(0,0,0,0.1);
fill: #fff;
background: linear-gradient( rgb(61, 161, 214), rgb(34, 136, 191) );
box-shadow: 0 1px 0 rgba(0,0,0,0.12);
border-radius:5px;
}
.kg-card .kg-card-toolbar button.kg-card-delete {
text-transform: none !important;
font-family: "ghosticons" !important;
font-size: 1rem;
line-height: 1;
font-weight: normal !important;
font-style: normal !important;
font-variant: normal !important;
}
.kg-card textarea {
@ -86,28 +158,11 @@
resize: none;
}
.card-handle button:hover {
.kg-card-toolbar button:hover {
background-color: #718087;
color: #fff;
}
.card-handle button.confirm {
animation-duration: 1s;
animation-name: rotate;
background-color: red;
color: #e9e8dd;
}
.card-handle button.move {
background-image: url('http: //localhost: 4200/assets/move.png');
background-color: #9fbb58;
margin-left: -10px;
margin-right: 20px;
cursor: -webkit-grab;
cursor: -moz-grab;
}
textarea.ed_code {

View file

@ -1,6 +1,6 @@
export default {
name: 'card-hr',
label: 'HR Card',
label: 'Divider',
icon: '',
genus: 'ember',
buttons: {

View file

@ -1,9 +1,9 @@
export default {
name: 'card-html',
label: 'HTML Card',
label: 'Embed',
icon: '',
genus: 'ember',
buttons: {
preview: true
edit: true
}
};

View file

@ -1,6 +1,6 @@
export default {
name: 'card-image',
label: 'Image Card',
label: 'Image',
icon: '',
genus: 'ember'
};

View file

@ -1,7 +1,7 @@
export default {
name: 'card-markdown',
label: 'Markdown Card',
label: 'Markdown',
icon: '',
genus: 'ember',
buttons: {preview: true}
buttons: {edit: true}
};

View file

@ -2,11 +2,12 @@ import Component from 'ember-component';
import layout from '../../templates/components/card-html';
import computed from 'ember-computed';
import observer from 'ember-metal/observer';
import {invokeAction} from 'ember-invoke-action';
export default Component.extend({
layout,
isEditing: true,
hasRendered: false,
save: observer('doSave', function () {
this.get('env').save(this.get('payload'), false);
}),
@ -28,8 +29,10 @@ export default Component.extend({
this.isEditing = !payload.hasOwnProperty('html');
this.isEditing = true;
},
didRender() {
actions: {
selectCard() {
invokeAction(this, 'selectCard');
}
}
});

View file

@ -25,9 +25,15 @@ export default Component.extend({
ajax: injectService(),
editing: observer('isEditing', function () {
if (!this.isEditing) {
this.set('preview', formatMarkdown([this.get('payload').markdown]));
}
// if (!this.isEditing) {
// this.set('preview', formatMarkdown([this.get('payload').markdown]));
// }
}),
preview: computed('value', function() {
return formatMarkdown([this.get('payload').markdown]);
}),
save: observer('doSave', function () {
this.get('env').save(this.get('payload'), false);
}),
value: computed('payload', {
@ -42,7 +48,6 @@ export default Component.extend({
}
}),
_uploadStarted() {
invokeAction(this, 'uploadStarted');
},
@ -231,6 +236,9 @@ export default Component.extend({
saveUrl() {
let url = this.get('url');
invokeAction(this, 'update', url);
},
selectCard() {
invokeAction(this, 'selectCard');
}
}

View file

@ -22,7 +22,8 @@ export default Component.extend({
layout,
classNames: ['editor-holder'],
emberCards: emberA([]),
selectedCard: null,
keyDownHandler: [],
init() {
this._super(...arguments);
@ -68,19 +69,9 @@ export default Component.extend({
}
let {editor} = this;
editor.willRender(() => {
// console.log(Ember.run.currentRunLoop);
// if (!Ember.run.currentRunLoop) {
// this._startedRunLoop = true;
// Ember.run.begin();
// }
});
editor.didRender(() => {
this.sendAction('loaded', editor);
// Ember.run.end();
});
editor.postDidChange(()=> {
run.join(() => {
@ -123,8 +114,12 @@ export default Component.extend({
// hack to track key up to focus back on the title when the up key is pressed
this.editor.element.addEventListener('keydown', (event) => {
if (event.keyCode === 38) {
let range = window.getSelection().getRangeAt(0); // get the actual range within the DOM.
if (event.keyCode === 38) { // up arrow
let selection = window.getSelection();
if (!selection.rangeCount) {
return;
}
let range = selection.getRangeAt(0); // get the actual range within the DOM.
let cursorPositionOnScreen = range.getBoundingClientRect();
let topOfEditor = this.editor.element.getBoundingClientRect().top;
if (cursorPositionOnScreen.top < topOfEditor + 33) {
@ -147,6 +142,18 @@ export default Component.extend({
}
});
// listen to keydown events outside of the editor, used to handle keydown events in the cards.
document.onkeydown = (event) => {
// if any of the keydown handlers return false then we return false therefore stopping the event from propogating.
return this.get('keyDownHandler').reduce((returnType, handler) => {
let result = handler(event);
if (returnType !== false) {
return result;
}
return returnType;
}, true);
};
},
// drag and drop images onto the editor
@ -163,9 +170,15 @@ export default Component.extend({
// makes sure the cursor is on screen except when selection is happening in which case the browser mostly ensures it.
// there is an issue with keyboard selection on some browsers though so the next step will be to record mouse and touch events.
cursorMoved() {
if (this.get('editor').range.isCollapsed) {
let editor = this.get('editor');
if (editor.range.isCollapsed) {
let scrollBuffer = 33; // the extra buffer to scroll.
let range = window.getSelection().getRangeAt(0); // get the actual range within the DOM.
let selection = window.getSelection();
if (!selection.rangeCount) {
return;
}
let range = selection.getRangeAt(0); // get the actual range within the DOM.
let position = range.getBoundingClientRect();
let windowHeight = window.innerHeight;
@ -174,11 +187,144 @@ export default Component.extend({
} else if (position.top < 0) {
this.domContainer.scrollTop += position.top - scrollBuffer;
}
if (editor.range && editor.range.headSection && editor.range.headSection.isCardSection) {
let id = $(editor.range.headSection.renderNode.element).find('.kg-card > div').attr('id');
// let id = card.find('div').attr('id');
window.getSelection().removeAllRanges();
this.send('selectCardHard', id);
} else {
this.send('deselectCard');
}
} else {
this.send('deselectCard');
}
},
willDestroy() {
this.editor.destroy();
this.send('deselectCard');
document.onkeydown = null;
},
actions: {
// thin border, shows that a card is selected but the user cannot delete the card with
// keyboard events.
// used when the content of the card is selected and it is editing.
selectCard(cardId) {
let card = this.get('emberCards').find((card) => card.id === cardId);
let cardHolder = $(`#${cardId}`).parent('.kg-card');
this.send('deselectCard');
cardHolder.addClass('selected');
cardHolder.removeClass('selected-hard');
this.set('selectedCard', card);
this.get('keyDownHandler').length = 0;
// cardHolder.focus();
document.onclick = (event) => {
let target = $(event.target);
let parent = target.parents('.kg-card');
if (!target.hasClass('kg-card') && (!parent.length || parent[0] !== cardHolder[0])) {
this.send('deselectCard');
}
};
},
// thicker border and with keyboard events for moving around the editor
// creating blocks under the card and deleting the card.
// used when selecting the card with the keyboard or clicking on the toolbar.
selectCardHard(cardId) {
let card = this.get('emberCards').find((card) => card.id === cardId);
let cardHolder = $(`#${cardId}`).parents('.kg-card');
this.send('deselectCard');
cardHolder.addClass('selected');
cardHolder.addClass('selected-hard');
this.set('selectedCard', card);
// cardHolder.focus();
document.onclick = (event) => {
let target = $(event.target);
let parent = target.parents('.kg-card');
if (!target.hasClass('kg-card') && (!parent.length || parent[0] !== cardHolder[0])) {
this.send('deselectCard');
}
};
let keyDownHandler = this.get('keyDownHandler');
keyDownHandler.push((event) => {
let editor = this.get('editor');
switch (event.keyCode) {
case 37: // arrow left
case 38: // arrow up
editor.post.sections.forEach((section) => {
let currentCard = $(section.renderNode.element);
if (currentCard.find(`#${cardId}`).length) {
if (section.prev) {
let range = section.prev.toRange();
range.tail.offset = 0;
editor.selectRange(range);
return;
} else {
$(this.titleQuery).focus();
}
}
});
return false;
case 39: // arrow right
case 40: // arrow down
editor.post.sections.forEach((section) => {
let currentCard = $(section.renderNode.element);
if (currentCard.find(`#${cardId}`).length) {
if (section.next) {
let range = section.next.toRange();
range.tail.offset = 0;
editor.selectRange(range);
return;
}
}
});
return false;
case 13: // enter
editor.post.sections.forEach((section) => {
let currentCard = $(section.renderNode.element);
if (currentCard.find(`#${cardId}`).length) {
if (section.next) {
editor.run((postEditor) => {
let newSection = editor.builder.createMarkupSection('p');
postEditor.insertSectionBefore(editor.post.sections, newSection, section.next);
postEditor.setRange(newSection.toRange()); // new Mobiledoc.Range(newSection.headPosition)
});
return;
} else {
editor.run((postEditor) => {
let newSection = editor.builder.createMarkupSection('p');
postEditor.insertSectionAtEnd(newSection);
postEditor.setRange(newSection.toRange());
});
}
}
});
return false;
case 27: // escape
this.send('selectCard', cardId);
return false;
case 8: // backspace
case 46: // delete
card.env.remove();
return false;
}
});
},
deselectCard() {
let selectedCard = this.get('selectedCard');
if (selectedCard) {
let cardHolder = $(`#${selectedCard.id}`).parent('.kg-card');
cardHolder.removeClass('selected');
cardHolder.removeClass('selected-hard');
this.set('selectedCard', null);
}
this.get('keyDownHandler').length = 0;
document.onclick = null;
}
}
});

View file

@ -13,22 +13,50 @@ export default Component.extend({
// for some reason `this` on did render actually refers to the editor object and not the card object, after render it seems okay.
run.schedule('afterRender', this,
() => {
let {env: {name}} = this.get('card');
let card = this.get('card');
if (card.newlyCreated) {
this.set('isEditing', true);
this.send('selectCard');
}
let {env: {name}} = card;
let mobiledocCard = this.$().parents('.__mobiledoc-card');
mobiledocCard.removeClass('__mobiledoc-card');
mobiledocCard.addClass('kg-card');
mobiledocCard.addClass(name ? `kg-${name}` : '');
mobiledocCard.attr('tabindex', 3);
}
);
},
actions: {
save() {
this.set('doSave', Date.now());
this.send('stopEdit');
this.send('selectCardHard');
// this.send('on-save');
},
toggleState() {
this.set('isEditing', !this.get('isEditing'));
},
selectCard() {
this.sendAction('selectCard', this.card.id);
},
deselectCard() {
this.sendAction('deselectCard', this.card.id);
},
selectCardHard() {
this.sendAction('selectCardHard', this.card.id);
},
delete() {
this.get('card').env.remove();
},
startEdit() {
this.set('isEditing', true);
},
stopEdit() {
this.set('isEditing', false);
}
}
});

View file

@ -66,12 +66,18 @@ export default function createCardFactory(toolbar) {
function setupEmberCard({env, options, payload}) {
let id = `GHOST_CARD_${uuid()}`;
let newlyCreated;
if (payload.newlyCreated) {
newlyCreated = true;
delete payload.newlyCreated;
}
let card = EmberObject.create({
id,
env,
options,
payload,
card: cardObject
card: cardObject,
newlyCreated
});
self.emberCards.pushObject(card);

View file

@ -264,7 +264,7 @@ export default function (editor, toolbar) {
cardMenu: true,
onClick: (editor, section) => {
editor.run((postEditor) => {
let card = postEditor.builder.createCardSection('card-markdown', {pos: 'top', markdown: editor.range.headSection.text});
let card = postEditor.builder.createCardSection('card-markdown', {pos: 'top', markdown: editor.range.headSection.text, newlyCreated: true});
// we can't replace a list item so we insert a card after it and then delete it.
if (editor.range.headSection.isListItem) {
editor.insertCard('card-markdown');

View file

@ -1,5 +1,5 @@
{{#if isEditing}}
{{gh-cm-editor value update=(action (mut value))}} {{!-- codemirror editor component from Ghost-Admin --}}
{{gh-cm-editor value update=(action (mut value)) focus-in=(action "selectCard")}} {{!-- codemirror editor component from Ghost-Admin --}}
{{else}}
{{{value}}}
{{/if}}

View file

@ -1,5 +1,5 @@
{{#if isEditing}}
{{{preview}}}
{{textarea value=value key-up="updateValue" focus-in=(action "selectCard")}}
{{else}}
{{textarea value=value key-up="updateValue"}}
{{{preview}}}
{{/if}}

View file

@ -1,6 +1,6 @@
{{#each emberCards as |card|}}
{{#ember-wormhole to=card.id}}
{{koenig-card card=card apiRoot=apiRoot assetPath=assetPath}}
{{koenig-card card=card apiRoot=apiRoot assetPath=assetPath selectCard=(action "selectCard") selectCardHard=(action "selectCardHard") deselectCard=(action "deselectCard")}}
{{/ember-wormhole}}
{{/each}}
<div class='gh-koenig'>

View file

@ -7,18 +7,27 @@
assetPath=assetPath
doSave=doSave
isEditing=isEditing
selectCard=(action "selectCard")
selectCardHard=(action "selectCardHard")
deselectCard=(action "deselectCard")
}}
<div class="card-handle">
{{#if card.card.buttons.preview}}
<button {{action "toggleState"}}>
{{#if isEditing}}
Edit
{{else}}
Preview
<div class="kg-card-toolbar">
<div class="button-group">
<label {{action "selectCardHard"}}>{{card.card.label}}:</label>
{{#if isEditing}}
<button {{action "toggleState"}} class='kg-card-button-text'>
Cancel
</button>
<button {{action "save"}} class='kg-card-button-save'>
Save
</button>
{{else}}
{{#if card.card.buttons.edit}}
<button {{action "toggleState"}} class='kg-card-button-text'>
Edit
</button>
{{/if}}
</button>
{{/if}}
{{#if card.card.buttons.save}}
<button {{action "save"}}>Save</button>
{{/if}}
<button class='kg-card-button kg-card-delete' {{action "delete"}}></button>
{{/if}}
</div>
</div>