mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-29 05:42:32 +03:00
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:
parent
f7dd3e39f9
commit
989ad7b9c1
95
ghost/admin/app/components/gh-title.js
Normal file
95
ghost/admin/app/components/gh-title.js
Normal 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();
|
||||
}
|
||||
});
|
@ -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
|
||||
/* ---------------------------------------------------------- */
|
||||
|
24
ghost/admin/app/styles/components/title.css
Normal file
24
ghost/admin/app/styles/components/title.css
Normal 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' */
|
||||
}
|
@ -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;
|
||||
|
1
ghost/admin/app/templates/components/gh-title.hbs
Normal file
1
ghost/admin/app/templates/components/gh-title.hbs
Normal file
@ -0,0 +1 @@
|
||||
<div contenteditable="true" data-placeholder="Your Post Title" class="gh-title">{{value}}</div>
|
@ -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>
|
||||
|
@ -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);
|
||||
// }
|
||||
|
@ -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(
|
||||
|
@ -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.
|
||||
|
24
ghost/admin/tests/integration/components/gh-title-test.js
Normal file
24
ghost/admin/tests/integration/components/gh-title-test.js
Normal 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);
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user