mirror of
https://github.com/TryGhost/Ghost-Admin.git
synced 2023-12-14 02:33:04 +01:00
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 - <kbd>Escape</kbd> 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 - <kbd>Enter</kbd> *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 - <kbd>Enter</kbd> *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
This commit is contained in:
parent
7fa52be861
commit
f0fe23d50b
9 changed files with 387 additions and 3 deletions
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 <a> 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);
|
||||
},
|
||||
|
|
273
lib/koenig-editor/addon/components/koenig-link-input.js
Normal file
273
lib/koenig-editor/addon/components/koenig-link-input.js
Normal file
|
@ -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();
|
||||
}
|
||||
}
|
||||
});
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
<input
|
||||
placeholder="Enter url"
|
||||
value={{href}}
|
||||
class="miw-100 pa2 pr6 mih-100 ba br3 shadow-2 f8 lh-heading tracked-2 outline-0"
|
||||
autofocus="true"
|
||||
oninput={{action (mut href) value="target.value"}}
|
||||
onkeydown={{action "inputKeydown"}}
|
||||
/>
|
||||
|
||||
{{#if href}}
|
||||
<button class="kg-input-bar-close" type="button" {{action "clear"}}>
|
||||
{{svg-jar "close" class="ih2"}}
|
||||
</button>
|
||||
{{/if}}
|
|
@ -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")}}
|
||||
</button>
|
||||
|
|
1
lib/koenig-editor/app/components/koenig-link-input.js
Normal file
1
lib/koenig-editor/app/components/koenig-link-input.js
Normal file
|
@ -0,0 +1 @@
|
|||
export {default} from 'koenig-editor/components/koenig-link-input';
|
24
tests/integration/components/koenig-link-input-test.js
Normal file
24
tests/integration/components/koenig-link-input-test.js
Normal file
|
@ -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);
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue