diff --git a/ghost/admin/app/components/gh-title.js b/ghost/admin/app/components/gh-title.js new file mode 100644 index 0000000000..f4c23747bf --- /dev/null +++ b/ghost/admin/app/components/gh-title.js @@ -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(); + } +}); diff --git a/ghost/admin/app/styles/app.css b/ghost/admin/app/styles/app.css index 1d76f2220f..059a985393 100644 --- a/ghost/admin/app/styles/app.css +++ b/ghost/admin/app/styles/app.css @@ -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 /* ---------------------------------------------------------- */ diff --git a/ghost/admin/app/styles/components/title.css b/ghost/admin/app/styles/components/title.css new file mode 100644 index 0000000000..273dec40a1 --- /dev/null +++ b/ghost/admin/app/styles/components/title.css @@ -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' */ +} diff --git a/ghost/admin/app/styles/layouts/main.css b/ghost/admin/app/styles/layouts/main.css index 0c6b492971..f49f51aae0 100644 --- a/ghost/admin/app/styles/layouts/main.css +++ b/ghost/admin/app/styles/layouts/main.css @@ -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; diff --git a/ghost/admin/app/templates/components/gh-title.hbs b/ghost/admin/app/templates/components/gh-title.hbs new file mode 100644 index 0000000000..305485a30d --- /dev/null +++ b/ghost/admin/app/templates/components/gh-title.hbs @@ -0,0 +1 @@ +
{{value}}
\ No newline at end of file diff --git a/ghost/admin/app/templates/editor/edit.hbs b/ghost/admin/app/templates/editor/edit.hbs index e39d5546aa..5af5b8e622 100644 --- a/ghost/admin/app/templates/editor/edit.hbs +++ b/ghost/admin/app/templates/editor/edit.hbs @@ -34,8 +34,29 @@
+ {{#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-view-title}} {{#if scheduleCountdown}}
diff --git a/ghost/admin/lib/gh-koenig/addon/components/gh-koenig.js b/ghost/admin/lib/gh-koenig/addon/components/gh-koenig.js index 122c122bac..e4952cef98 100644 --- a/ghost/admin/lib/gh-koenig/addon/components/gh-koenig.js +++ b/ghost/admin/lib/gh-koenig/addon/components/gh-koenig.js @@ -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); +// } diff --git a/ghost/admin/tests/acceptance/editor-test.js b/ghost/admin/tests/acceptance/editor-test.js index 81af1ab964..2858e3579d 100644 --- a/ghost/admin/tests/acceptance/editor-test.js +++ b/ghost/admin/tests/acceptance/editor-test.js @@ -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( diff --git a/ghost/admin/tests/helpers/editor-helpers.js b/ghost/admin/tests/helpers/editor-helpers.js index f000b3fd80..4dfbe01fd4 100644 --- a/ghost/admin/tests/helpers/editor-helpers.js +++ b/ghost/admin/tests/helpers/editor-helpers.js @@ -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. diff --git a/ghost/admin/tests/integration/components/gh-title-test.js b/ghost/admin/tests/integration/components/gh-title-test.js new file mode 100644 index 0000000000..9207f548d1 --- /dev/null +++ b/ghost/admin/tests/integration/components/gh-title-test.js @@ -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); + }); +});