From f0fe23d50bd0c9206ede8af74d0648183d719c06 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Fri, 6 Apr 2018 17:49:16 +0100 Subject: [PATCH] Koenig - Link creation/editing via formatting toolbar refs https://github.com/TryGhost/Ghost/issues/9505 - wire up the link button in the toolbar to set a `linkRange` property on `{{koenig-editor}}` - add `{{koenig-link-input}}` that is shown when `{{koenig-editor}}` has a `linkRange` set - Escape will cancel the link input - clicking outside the input will cancel the link input - previously selected text will be re-selected on cancel - if an existing link was selected (or partially selected) then pre-fill the link input with the `href` - `X` is shown when there's a href value and clicking will clear the input - Enter *with* a href value will remove all links from text that is touched by the selection and create a new link across only the selected text - Enter *with no* href value will remove all links touched by the selection - fixed toolbar tick positioning that was 8px off after change to Spirit classes --- app/styles/patterns/_shame.css | 36 +++ .../addon/components/koenig-editor.js | 13 + .../addon/components/koenig-link-input.js | 273 ++++++++++++++++++ .../addon/components/koenig-toolbar.js | 16 +- .../templates/components/koenig-editor.hbs | 12 + .../components/koenig-link-input.hbs | 14 + .../templates/components/koenig-toolbar.hbs | 1 + .../app/components/koenig-link-input.js | 1 + .../components/koenig-link-input-test.js | 24 ++ 9 files changed, 387 insertions(+), 3 deletions(-) create mode 100644 lib/koenig-editor/addon/components/koenig-link-input.js create mode 100644 lib/koenig-editor/addon/templates/components/koenig-link-input.hbs create mode 100644 lib/koenig-editor/app/components/koenig-link-input.js create mode 100644 tests/integration/components/koenig-link-input-test.js diff --git a/app/styles/patterns/_shame.css b/app/styles/patterns/_shame.css index 1561964a64..8197795cb1 100644 --- a/app/styles/patterns/_shame.css +++ b/app/styles/patterns/_shame.css @@ -17,3 +17,39 @@ content: " "; display: table; } + +/* similar to .kg-action-bar to add a positionable triangle to a popup */ +.kg-input-bar:after, +.kg-input-bar:before { + position: absolute; + /* bottom: 150%; */ + /* left: 50%; */ + /* margin-left: -5px; */ + top: 34px; + left: calc(50% - 8px); + width: 0; + content: ""; + font-size: 0; + line-height: 0; +} +.kg-input-bar:after { + margin-left: 1px; + border-top: 7px solid #fff; + border-right: 7px solid transparent; + border-left: 7px solid transparent; +} +.kg-input-bar:before { + border-top: 8px solid var(--darkgrey-d2); + border-right: 8px solid transparent; + border-left: 8px solid transparent; +} + +.kg-input-bar-close { + position: absolute; + top: 11px; + right: 9px; + left: auto; + line-height: 1.2rem; + z-index: 100; + cursor: pointer; +} diff --git a/lib/koenig-editor/addon/components/koenig-editor.js b/lib/koenig-editor/addon/components/koenig-editor.js index 508c992bef..e34c08af2b 100644 --- a/lib/koenig-editor/addon/components/koenig-editor.js +++ b/lib/koenig-editor/addon/components/koenig-editor.js @@ -84,6 +84,7 @@ export default Component.extend({ activeSectionTagNames: null, selectedRange: null, componentCards: null, + linkRange: null, // private properties _localMobiledoc: null, @@ -379,6 +380,18 @@ export default Component.extend({ this.deselectCard(card); }, + // range should be set to the full extent of the selection or the + // appropriate markup. If there's a selection when the link edit + // component renders it will re-select when finished which should + // trigger the normal toolbar + editLink(range) { + this.set('linkRange', range); + }, + + cancelEditLink() { + this.set('linkRange', null); + }, + deleteCard(card, cursorMovement = NO_CURSOR_MOVEMENT) { this._deleteCard(card, cursorMovement); }, diff --git a/lib/koenig-editor/addon/components/koenig-link-input.js b/lib/koenig-editor/addon/components/koenig-link-input.js new file mode 100644 index 0000000000..d46529eac5 --- /dev/null +++ b/lib/koenig-editor/addon/components/koenig-link-input.js @@ -0,0 +1,273 @@ +import Component from '@ember/component'; +import layout from '../templates/components/koenig-link-input'; +import {TOOLBAR_MARGIN} from './koenig-toolbar'; +import {computed} from '@ember/object'; +import {htmlSafe} from '@ember/string'; +import {run} from '@ember/runloop'; + +// pixels that should be added to the `left` property of the tick adjustment styles +// TODO: handle via CSS? +const TICK_ADJUSTMENT = 8; + +// TODO: move to a util +function getScrollParent(node) { + const isElement = node instanceof HTMLElement; + const overflowY = isElement && window.getComputedStyle(node).overflowY; + const isScrollable = overflowY !== 'visible' && overflowY !== 'hidden'; + + if (!node) { + return null; + } else if (isScrollable && node.scrollHeight >= node.clientHeight) { + return node; + } + + return getScrollParent(node.parentNode) || document.body; +} + +export default Component.extend({ + layout, + + attributeBindings: ['style'], + classNames: ['kg-input-bar', 'absolute', 'z-999'], + + // public attrs + editor: null, + linkRange: null, + selectedRange: null, + + // internal properties + top: null, + left: null, + right: null, + href: '', + + // private properties + _selectedRange: null, + _windowRange: null, + _onMousedownHandler: null, + _onMouseupHandler: null, + + // closure actions + cancel() {}, + + /* computed properties -------------------------------------------------- */ + + style: computed('top', 'left', 'right', function () { + let position = this.getProperties('top', 'left', 'right'); + let styles = Object.keys(position).map((style) => { + if (position[style] !== null) { + return `${style}: ${position[style]}px`; + } + }); + + return htmlSafe(styles.compact().join('; ')); + }), + + /* lifecycle hooks ------------------------------------------------------ */ + + init() { + this._super(...arguments); + + // record the range now because the property is bound and will update + // as we make changes whilst calculating the link position + this._selectedRange = this.get('selectedRange'); + this._linkRange = this.get('linkRange'); + + // grab a window range so that we can use getBoundingClientRect. Using + // document.createRange is more efficient than doing editor.setRange + // because it doesn't trigger all of the selection changing side-effects + // TODO: extract MobiledocRange->NativeRange into a util + let editor = this.get('editor'); + let cursor = editor.cursor; + let {head, tail} = this._linkRange; + let {node: headNode, offset: headOffset} = cursor._findNodeForPosition(head); + let {node: tailNode, offset: tailOffset} = cursor._findNodeForPosition(tail); + let range = document.createRange(); + range.setStart(headNode, headOffset); + range.setEnd(tailNode, tailOffset); + this._windowRange = range; + + // wait until rendered to position so that we have access to this.element + run.schedule('afterRender', this, function () { + this._positionToolbar(); + this._focusInput(); + }); + + // grab an existing href value if there is one + this._getHrefFromMarkup(); + + // watch the window for mousedown events so that we can close the menu + // when we detect a click outside + this._onMousedownHandler = run.bind(this, this._handleMousedown); + window.addEventListener('mousedown', this._onMousedownHandler); + + // watch for keydown events so that we can close the menu on Escape + this._onKeydownHandler = run.bind(this, this._handleKeydown); + window.addEventListener('keydown', this._onKeydownHandler); + }, + + willDestroyElement() { + this._super(...arguments); + this._removeStyleElement(); + window.removeEventListener('mousedown', this._onMousedownHandler); + window.removeEventListener('keydown', this._onKeydownHandler); + }, + + actions: { + inputKeydown(event) { + if (event.code === 'Enter') { + // prevent Enter from triggering in the editor and removing text + event.preventDefault(); + + let href = this.get('href'); + + // create a single editor runloop here so that we don't get + // separate remove and replace ops pushed onto the undo stack + this.get('editor').run((postEditor) => { + if (href) { + this._replaceLink(href, postEditor); + } else { + this._removeLinks(postEditor); + } + }); + + this._cancelAndReselect(); + } + }, + + clear() { + this.set('href', ''); + this._focusInput(); + } + }, + + // if we have a single link or a slice of a single link selected, grab the + // href and adjust our linkRange to encompass the whole link + _getHrefFromMarkup() { + let {headMarker, tailMarker} = this._linkRange; + if (headMarker === tailMarker || headMarker.next === tailMarker) { + let linkMarkup = tailMarker.markups.findBy('tagName', 'a'); + if (linkMarkup) { + this.set('href', linkMarkup.attributes.href); + this._linkRange = this._linkRange.expandByMarker(marker => !!marker.markups.includes(linkMarkup)); + } + } + }, + + _replaceLink(href, postEditor) { + this._removeLinks(postEditor); + let linkMarkup = postEditor.builder.createMarkup('a', {href}); + postEditor.toggleMarkup(linkMarkup, this._linkRange); + }, + + // loop over all markers that are touched by linkRange, removing any 'a' + // markups on them to clear all links + _removeLinks(postEditor) { + let {headMarker, tailMarker} = this.get('linkRange'); + let curMarker = headMarker; + + while (curMarker && curMarker !== tailMarker.next) { + curMarker.markups.filterBy('tagName', 'a').forEach((markup) => { + curMarker.removeMarkup(markup); + postEditor._markDirty(curMarker); + }); + curMarker = curMarker.next; + } + }, + + _cancelAndReselect() { + this.cancel(); + if (this._selectedRange) { + this.get('editor').selectRange(this._selectedRange); + } + }, + + _focusInput() { + let scrollParent = getScrollParent(this.element); + let scrollTop = scrollParent.scrollTop; + + this.element.querySelector('input').focus(); + + // reset the scroll position to avoid jumps + // TODO: why does the input focus cause a scroll to the bottom of the doc? + scrollParent.scrollTop = scrollTop; + }, + + // TODO: largely shared with {{koenig-toolbar}} code - extract to a shared util? + _positionToolbar() { + let containerRect = this.element.parentNode.getBoundingClientRect(); + let rangeRect = this._windowRange.getBoundingClientRect(); + let {width, height} = this.element.getBoundingClientRect(); + let newPosition = {}; + + // rangeRect is relative to the viewport so we need to subtract the + // container measurements to get a position relative to the container + newPosition = { + top: rangeRect.top - containerRect.top - height - TOOLBAR_MARGIN, + left: rangeRect.left - containerRect.left + rangeRect.width / 2 - width / 2, + right: null + }; + + let tickPosition = 50; + // don't overflow left boundary + if (newPosition.left < 0) { + newPosition.left = 0; + + // calculate the tick percentage position + let absTickPosition = rangeRect.left - containerRect.left + rangeRect.width / 2; + tickPosition = absTickPosition / width * 100; + if (tickPosition < 5) { + tickPosition = 5; + } + } + // same for right boundary + if (newPosition.left + width > containerRect.width) { + newPosition.left = null; + newPosition.right = 0; + + // calculate the tick percentage position + let absTickPosition = rangeRect.right - containerRect.right - rangeRect.width / 2; + tickPosition = 100 + absTickPosition / width * 100; + if (tickPosition > 95) { + tickPosition = 95; + } + } + + // the tick is a pseudo-element so we the only way we can affect it's + // style is by adding a style element to the head + this._removeStyleElement(); // reset to base styles + if (tickPosition !== 50) { + this._addStyleElement(`left: calc(${tickPosition}% - ${TICK_ADJUSTMENT}px)`); + } + + // update the toolbar position + this.setProperties(newPosition); + }, + + _addStyleElement(styles) { + let styleElement = document.createElement('style'); + styleElement.id = `${this.elementId}-style`; + styleElement.innerHTML = `#${this.elementId}:before, #${this.elementId}:after { ${styles} }`; + document.head.appendChild(styleElement); + }, + + _removeStyleElement() { + let styleElement = document.querySelector(`#${this.elementId}-style`); + if (styleElement) { + styleElement.remove(); + } + }, + + _handleMousedown(event) { + if (!event.target.closest(`#${this.elementId}`)) { + // no need to re-select for mouse clicks + this.cancel(); + } + }, + + _handleKeydown(event) { + if (event.code === 'Escape') { + this._cancelAndReselect(); + } + } +}); diff --git a/lib/koenig-editor/addon/components/koenig-toolbar.js b/lib/koenig-editor/addon/components/koenig-toolbar.js index 132ec3a3a4..55bc2f7d48 100644 --- a/lib/koenig-editor/addon/components/koenig-toolbar.js +++ b/lib/koenig-editor/addon/components/koenig-toolbar.js @@ -12,7 +12,12 @@ import {task, timeout} from 'ember-concurrency'; // animation occurs via CSS transitions // position is kept after hiding, it's made inoperable by CSS pointer-events -const TOOLBAR_TOP_MARGIN = 15; +// pixels that should be added to separate toolbar from positioning rect +export const TOOLBAR_MARGIN = 15; + +// pixels that should be added to the `left` property of the tick adjustment styles +// TODO: handle via CSS? +const TICK_ADJUSTMENT = 8; export default Component.extend({ layout, @@ -42,6 +47,7 @@ export default Component.extend({ // closure actions toggleMarkup() {}, toggleSection() {}, + editLink() {}, /* computed properties -------------------------------------------------- */ @@ -113,6 +119,10 @@ export default Component.extend({ toggleSection(sectionName) { this.toggleSection(sectionName); + }, + + editLink() { + this.editLink(this.get('editorRange')); } }, @@ -207,7 +217,7 @@ export default Component.extend({ // rangeRect is relative to the viewport so we need to subtract the // container measurements to get a position relative to the container newPosition = { - top: rangeRect.top - containerRect.top - height - TOOLBAR_TOP_MARGIN, + top: rangeRect.top - containerRect.top - height - TOOLBAR_MARGIN, left: rangeRect.left - containerRect.left + rangeRect.width / 2 - width / 2, right: null }; @@ -241,7 +251,7 @@ export default Component.extend({ // style is by adding a style element to the head this._removeStyleElement(); // reset to base styles if (tickPosition !== 50) { - this._addStyleElement(`left: ${tickPosition}%`); + this._addStyleElement(`left: calc(${tickPosition}% - ${TICK_ADJUSTMENT}px)`); } // update the toolbar position diff --git a/lib/koenig-editor/addon/templates/components/koenig-editor.hbs b/lib/koenig-editor/addon/templates/components/koenig-editor.hbs index 889a3545f0..a606f6029b 100644 --- a/lib/koenig-editor/addon/templates/components/koenig-editor.hbs +++ b/lib/koenig-editor/addon/templates/components/koenig-editor.hbs @@ -4,13 +4,25 @@ {{!-- pop-up markup toolbar is shown when there's a selection --}} {{koenig-toolbar + editor=editor editorRange=selectedRange activeMarkupTagNames=activeMarkupTagNames activeSectionTagNames=activeSectionTagNames toggleMarkup=(action "toggleMarkup") toggleSection=(action "toggleSection") + editLink=(action "editLink") }} +{{!-- pop-up link editing toolbar --}} +{{#if linkRange}} + {{koenig-link-input + editor=editor + linkRange=linkRange + selectedRange=selectedRange + cancel=(action "cancelEditLink") + }} +{{/if}} + {{!-- (+) icon and pop-up menu --}} {{koenig-plus-menu editor=editor diff --git a/lib/koenig-editor/addon/templates/components/koenig-link-input.hbs b/lib/koenig-editor/addon/templates/components/koenig-link-input.hbs new file mode 100644 index 0000000000..e7b551c529 --- /dev/null +++ b/lib/koenig-editor/addon/templates/components/koenig-link-input.hbs @@ -0,0 +1,14 @@ + + +{{#if href}} + +{{/if}} diff --git a/lib/koenig-editor/addon/templates/components/koenig-toolbar.hbs b/lib/koenig-editor/addon/templates/components/koenig-toolbar.hbs index dafdf16706..b53acc652b 100644 --- a/lib/koenig-editor/addon/templates/components/koenig-toolbar.hbs +++ b/lib/koenig-editor/addon/templates/components/koenig-toolbar.hbs @@ -80,6 +80,7 @@ type="button" title="Link" class="dib dim-lite pa3 pt2 pb2 link" + {{action "editLink"}} > {{svg-jar "koenig/kg-link" class=(concat (if activeMarkupTagNames.isA "stroke-blue-l2" "stroke-white") " w4 h4 nudge-top--1")}} diff --git a/lib/koenig-editor/app/components/koenig-link-input.js b/lib/koenig-editor/app/components/koenig-link-input.js new file mode 100644 index 0000000000..1329013eff --- /dev/null +++ b/lib/koenig-editor/app/components/koenig-link-input.js @@ -0,0 +1 @@ +export {default} from 'koenig-editor/components/koenig-link-input'; diff --git a/tests/integration/components/koenig-link-input-test.js b/tests/integration/components/koenig-link-input-test.js new file mode 100644 index 0000000000..1d5378e7b0 --- /dev/null +++ b/tests/integration/components/koenig-link-input-test.js @@ -0,0 +1,24 @@ +import hbs from 'htmlbars-inline-precompile'; +import {describe, it} from 'mocha'; +import {expect} from 'chai'; +import {setupComponentTest} from 'ember-mocha'; + +describe('Integration: Component: koenig-link-input', function () { + setupComponentTest('koenig-link-input', { + integration: true + }); + + it.skip('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` + // {{#koenig-link-input}} + // template content + // {{/koenig-link-input}} + // `); + + this.render(hbs`{{koenig-link-input}}`); + expect(this.$()).to.have.length(1); + }); +});