Skip to content
This repository has been archived by the owner on Nov 28, 2022. It is now read-only.

Commit

Permalink
Koenig - Link creation/editing via formatting toolbar
Browse files Browse the repository at this point in the history
refs TryGhost/Ghost#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
  • Loading branch information
kevinansfield committed Apr 6, 2018
1 parent 7fa52be commit f0fe23d
Show file tree
Hide file tree
Showing 9 changed files with 387 additions and 3 deletions.
36 changes: 36 additions & 0 deletions app/styles/patterns/_shame.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
13 changes: 13 additions & 0 deletions lib/koenig-editor/addon/components/koenig-editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export default Component.extend({
activeSectionTagNames: null,
selectedRange: null,
componentCards: null,
linkRange: null,

// private properties
_localMobiledoc: null,
Expand Down Expand Up @@ -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);
},
Expand Down
273 changes: 273 additions & 0 deletions lib/koenig-editor/addon/components/koenig-link-input.js
Original file line number Diff line number Diff line change
@@ -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();
}
}
});
16 changes: 13 additions & 3 deletions lib/koenig-editor/addon/components/koenig-toolbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -42,6 +47,7 @@ export default Component.extend({
// closure actions
toggleMarkup() {},
toggleSection() {},
editLink() {},

/* computed properties -------------------------------------------------- */

Expand Down Expand Up @@ -113,6 +119,10 @@ export default Component.extend({

toggleSection(sectionName) {
this.toggleSection(sectionName);
},

editLink() {
this.editLink(this.get('editorRange'));
}
},

Expand Down Expand Up @@ -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
};
Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit f0fe23d

Please sign in to comment.