Multi line title (#575)

refs https://github.com/TryGhost/Ghost/issues/7754
- The title is now a contenteditable div which stretches and wraps to behave like the editor.
- It also tries to seemlessly move the cursor between the editor and title to make one coherent editing experience.
This commit is contained in:
Ryan McCarvill 2017-03-15 02:50:30 +13:00 committed by Kevin Ansfield
parent f7dd3e39f9
commit 989ad7b9c1
10 changed files with 252 additions and 9 deletions

View File

@ -0,0 +1,95 @@
import Component from 'ember-component';
export default Component.extend({
_cachedValue: '',
_mutationObserver: null,
tagName: 'h2',
didRender() {
if (this._rendered) {
return;
}
let title = this.$('div');
if (!this.get('value')) {
title.addClass('no-content');
}
title[0].onkeydown = (event) => {
// block the browser format keys.
if (event.ctrlKey || event.metaKey) {
switch (event.keyCode) {
case 66: // B
case 98: // b
case 73: // I
case 105: // i
case 85: // U
case 117: // u
return false;
}
}
if (event.keyCode === 13) {
// enter
return false;
}
// down key
// if we're within ten pixels of the bottom of this element then we try and figure out where to position
// the cursor in the editor.
if (event.keyCode === 40) {
let range = window.getSelection().getRangeAt(0); // get the actual range within the DOM.
let cursorPositionOnScreen = range.getBoundingClientRect();
let offset = title.offset();
let bottomOfHeading = offset.top + title.height();
if (cursorPositionOnScreen.bottom > bottomOfHeading - 33) {
let {editor} = window; // hmmm, this is nasty!
// We need to pass the editor instance so that we can `this.get('editor');`
// but the editor instance is within the component and not exposed.
// there's also a dependency that the editor will have with the title and the title will have with the editor
// so that the cursor can move both ways (up and down) between them.
// see `lib/gh-koenig/addon/gh-koenig.js` and the function `findCursorPositionFromPixel` which should actually be
// encompassed here.
let loc = editor.element.getBoundingClientRect();
let cursorPositionInEditor = editor.positionAtPoint(cursorPositionOnScreen.left, loc.top);
if (cursorPositionInEditor.isBlank) {
editor.element.focus();
} else {
editor.selectRange(cursorPositionInEditor.toRange());
}
return false;
}
}
title.removeClass('no-content');
};
// setup mutation observer
let mutationObserver = new MutationObserver(() => {
// on mutate we update.
if (title[0].textContent !== '') {
title.removeClass('no-content');
} else {
title.addClass('no-content');
}
// sanity check if there is formatting reset it.
let {textContent} = title[0]; // eslint-disable-line
if (title[0].innerHTML !== textContent && title[0].innerHTML) {
title[0].innerHTML = textContent;
// todo: retain the range position.
}
if (this.get('_cachedValue') !== textContent) {
this.set('_cacheValue', textContent);
this.sendAction('onChange', textContent);
this.sendAction('update', textContent);
}
});
mutationObserver.observe(title[0], {childList: true, characterData: true, subtree: true});
this.set('_mutationObserver', mutationObserver);
this.set('_rendered', true);
},
willDestroyElement() {
this.get('_mutationObserver').disconnect();
}
});

View File

@ -31,7 +31,7 @@
@import "components/selectize.css";
@import "components/power-select.css";
@import "components/publishmenu.css";
@import "components/title.css";
/* Layouts: Groups of Components
/* ---------------------------------------------------------- */

View File

@ -0,0 +1,24 @@
.gh-title {
padding:0 0 0 1px; /* need some left padding otherwise the cursor isn't visible on the left hand side */
margin:0;
outline:none;
position:relative;
width:100%;
letter-spacing:0.8px;
font-weight: bold;
font-size: 3.2rem;
line-height: 3.2em;
}
/* Place holder content that displays in the title if it is empty */
.gh-title.no-content:after {
content: attr(data-placeholder);
color: #bbb;
cursor: text;
position:absolute;
top:0;
font-size: 3.2rem;
line-height: 3.2em;
font-weight: bold;
min-width: 30rem; /* hack it's defaulting just to enough width for the 'Your' in 'Your Post Title' */
}

View File

@ -489,7 +489,6 @@ body > .ember-view:not(.default-liquid-destination) {
margin: 0;
padding: 0;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 2rem;
line-height: 1.2em;
font-weight: 400;

View File

@ -0,0 +1 @@
<div contenteditable="true" data-placeholder="Your Post Title" class="gh-title">{{value}}</div>

View File

@ -34,8 +34,29 @@
</header>
<div class="gh-editor-container">
<div class="gh-editor-inner">
{{#gh-view-title classNames="gh-editor-title" openMobileMenu="openMobileMenu"}}
{{gh-trim-focus-input model.titleScratch type="text" id="entry-title" placeholder="Your Post Title" tabindex="1" shouldFocus=shouldFocusTitle focus-out="updateTitle" update=(action (perform updateTitle)) keyDown=(action "titleKeyDown")}}
{{gh-title
value=(readonly model.titleScratch)
onChange=(action (mut model.titleScratch))
tabindex="1"
shouldFocus=shouldFocusTitle
focus-out="updateTitle"
update=(action (perform updateTitle))
keyDown=(action "titleKeyDown")
id='gh-title'
}}
<!--{ {gh-trim-focus-input
model.titleScratch
type="text"
id="entry-title"
placeholder="Your Post Title"
tabindex="1"
shouldFocus=shouldFocusTitle
focus-out="updateTitle"
update=(action (perform updateTitle))
keyDown=(action "titleKeyDown")
} }-->
{{/gh-view-title}}
{{#if scheduleCountdown}}
<time datetime="{{post.publishedAtUTC}}" class="gh-notification gh-notification-schedule">
@ -51,6 +72,7 @@
apiRoot=apiRoot
assetPath=assetPath
tabindex=2
titleQuery='#gh-title div'
containerSelector='.gh-editor-container'
}}
</div>

View File

@ -7,6 +7,7 @@ import {MOBILEDOC_VERSION} from 'mobiledoc-kit/renderers/mobiledoc';
import createCardFactory from '../lib/card-factory';
import defaultCommands from '../options/default-commands';
import editorCards from '../cards/index';
import $ from 'jquery';
// import { VALID_MARKUP_SECTION_TAGNAMES } from 'mobiledoc-kit/models/markup-section'; //the block elements supported by mobile-doc
export const BLANK_DOC = {
@ -117,6 +118,30 @@ export default Component.extend({
this.editor.cursorDidChange(() => this.cursorMoved());
// 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.
let cursorPositionOnScreen = range.getBoundingClientRect();
let topOfEditor = this.editor.element.getBoundingClientRect().top;
if (cursorPositionOnScreen.top < topOfEditor + 33) {
let $title = $(this.titleQuery);
// let offset = findCursorPositionFromPixel($title[0].firstChild, cursorPositionOnScreen.left);
// let newRange = document.createRange();
// newRange.collapse(true);
// newRange.setStart($title[0].firstChild, offset);
// newRange.setEnd($title[0].firstChild, offset);
// updateCursor(newRange);
$title[0].focus();
return false;
}
}
});
},
// drag and drop images onto the editor
@ -149,3 +174,33 @@ export default Component.extend({
}
});
// // code for moving the cursor into the correct position of the title: (is buggy)
// // find the cursor position based on a pixel offset of an element.
// // used to move the cursor vertically into the title.
// function findCursorPositionFromPixel(el, horizontal_offset) {
// let len = el.textContent.length;
// let range = document.createRange();
// for(let i = len -1; i > -1; i--) {
// range.setStart(el, i);
// range.setEnd(el, i + 1);
// let rect = range.getBoundingClientRect();
// if (rect.top === rect.bottom) {
// continue;
// }
// if(rect.left <= horizontal_offset && rect.right >= horizontal_offset) {
// return i + (horizontal_offset >= (rect.left + rect.right) / 2 ? 1 : 0); // if the horizontal_offset is on the left hand side of the
// // character then return `i`, if it's on the right return `i + 1`
// }
// }
// return el.length;
// }
// // update the cursor position.
// function updateCursor(range) {
// let selection = window.getSelection();
// selection.removeAllRanges();
// selection.addRange(range);
// }

View File

@ -12,7 +12,7 @@ import {invalidateSession, authenticateSession} from 'ghost-admin/tests/helpers/
import Mirage from 'ember-cli-mirage';
import sinon from 'sinon';
import testSelector from 'ember-test-selectors';
import {titleRendered} from '../helpers/editor-helpers';
describe('Acceptance: Editor', function() {
let application;
@ -25,7 +25,7 @@ describe('Acceptance: Editor', function() {
});
it('redirects to signin when not authenticated', function () {
server.create('user'); // necessray for post-author association
server.create('user'); // necesary for post-author association
server.create('post');
invalidateSession(application);
@ -397,10 +397,18 @@ describe('Acceptance: Editor', function() {
.to.equal('/editor/1');
});
// Test title validation
fillIn('input[id="entry-title"]', Array(160).join('a'));
triggerEvent('input[id="entry-title"]', 'blur');
click('.gh-btn.gh-btn-sm.js-publish-button');
andThen(() => {
titleRendered();
});
andThen(() => {
let title = find('#gh-title div');
title.html(Array(160).join('a'));
});
andThen(() => {
click('.gh-btn.gh-btn-sm.js-publish-button');
});
andThen(() => {
expect(

View File

@ -15,6 +15,21 @@ export function editorRendered() {
});
}
// polls the title until it's started.
export function titleRendered() {
return Ember.Test.promise(function (resolve) { // eslint-disable-line
function checkTitle() {
let title = $('#gh-title div');
if (title[0]) {
return resolve();
} else {
window.requestAnimationFrame(checkTitle);
}
}
checkTitle();
});
}
// simulates text inputs into the editor, unfortunately the helper Ember helper functions
// don't work on content editable so we have to manipuate the text input event manager
// in mobiledoc-kit directly. This is a private API.

View File

@ -0,0 +1,24 @@
import {expect} from 'chai';
import {describe, it} from 'mocha';
import {setupComponentTest} from 'ember-mocha';
import hbs from 'htmlbars-inline-precompile';
describe('Integration | Component | gh title', function() {
setupComponentTest('gh-title', {
integration: true
});
it('renders', function() {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.on('myAction', function(val) { ... });
// Template block usage:
// this.render(hbs`
// {{#gh-title}}
// template content
// {{/gh-title}}
// `);
this.render(hbs`{{gh-title}}`);
expect(this.$()).to.have.length(1);
});
});