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

Commit

Permalink
Koenig - Link hover toolbar
Browse files Browse the repository at this point in the history
refs TryGhost/Ghost#9505
- disable `mobiledoc-kit`'s built-in link tooltip
- add `{{koenig-link-toolbar}}` component
    - shows toolbar above a link when it is hovered with the mouse
    - hides toolbar when a link isn't hovered
    - has a clickable link with the URL, opens in a new tab
    - edit button switches display to the link input toolbar
    - delete button removes link markup from the link
  • Loading branch information
kevinansfield committed Apr 11, 2018
1 parent 2f6132e commit 07aba16
Show file tree
Hide file tree
Showing 8 changed files with 314 additions and 0 deletions.
1 change: 1 addition & 0 deletions lib/koenig-editor/addon/components/koenig-editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ export default Component.extend({
// create a new editor
let editorOptions = this.get('editorOptions');
editorOptions.mobiledoc = mobiledoc;
editorOptions.showLinkTooltips = false;

let componentHooks = {
// triggered when a card section is added to the mobiledoc
Expand Down
233 changes: 233 additions & 0 deletions lib/koenig-editor/addon/components/koenig-link-toolbar.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import Component from '@ember/component';
import layout from '../templates/components/koenig-link-toolbar';
// import {TOOLBAR_MARGIN} from './koenig-toolbar';
import {computed} from '@ember/object';
import {getEventTargetMatchingTag} from 'mobiledoc-kit/utils/element-utils';
import {htmlSafe} from '@ember/string';
import {run} from '@ember/runloop';

// gap between link and toolbar bottom
const TOOLBAR_MARGIN = 8;
// extra padding to reduce the likelyhood of unexpected hiding
// TODO: improve behaviour with a mouseout timeout or creating a bounding box
// and watching mousemove
const TOOLBAR_PADDING = 12;

// ms to wait before showing the tooltip
const DELAY = 200;

export default Component.extend({
layout,

attributeBindings: ['style'],
classNames: ['absolute', 'z-999'],

// public attrs
container: null,
editor: null,
linkRange: null,
selectedRange: null,

// internal attrs
url: 'http://example.com',
showToolbar: false,
top: null,
left: -1000,
right: null,

// private attrs
_canShowToolbar: true,
_eventListeners: null,

// closure actions
editLink() {},

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

style: computed('showToolbar', '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`;
}
});

// ensure hidden toolbar is non-interactive
if (this.get('showToolbar')) {
styles.push('pointer-events: auto !important');
// add margin-bottom so that there's no gap between the link and
// the toolbar to avoid closing when mouse moves between elements
styles.push(`padding-bottom: ${TOOLBAR_PADDING}px`);
} else {
styles.push('pointer-events: none !important');
}

return htmlSafe(styles.compact().join('; '));
}),

/* lifecycle hooks ------------------------------------------------------ */

init() {
this._super(...arguments);
this._eventListeners = [];
},

didReceiveAttrs() {
this._super(...arguments);

// don't show popups if link edit or formatting toolbar is shown
// TODO: have a service for managing UI state?
if (this.get('linkRange') || (this.get('selectedRange') && !this.get('selectedRange').isCollapsed)) {
this._cancelTimeouts();
this.set('showToolbar', false);
this._canShowToolbar = false;
} else {
this._canShowToolbar = true;
}
},

didInsertElement() {
this._super(...arguments);

let container = this.get('container');
this._addEventListener(container, 'mouseover', this._handleMouseover);
this._addEventListener(container, 'mouseout', this._handleMouseout);
},

willDestroyElement() {
this._super(...arguments);
this._removeAllEventListeners();
},

/* actions -------------------------------------------------------------- */

actions: {
edit() {
// get range that covers link
let linkRange = this._getLinkRange();
this.editLink(linkRange);
},

remove() {
let editor = this.get('editor');
let linkRange = this._getLinkRange();
let editorRange = editor.range;
editor.run((postEditor) => {
postEditor.toggleMarkup('a', linkRange);
});
this.set('showToolbar', false);
editor.selectRange(editorRange);
}
},

/* private methods ------------------------------------------------------ */

_getLinkRange() {
if (!this._target) {
return;
}

let editor = this.get('editor');
let rect = this._target.getBoundingClientRect();
let x = rect.x + rect.width / 2;
let y = rect.y + rect.height / 2;
let position = editor.positionAtPoint(x, y);
let linkMarkup = position.marker && position.marker.markups.findBy('tagName', 'a');
if (linkMarkup) {
let linkRange = position.toRange().expandByMarker(marker => !!marker.markups.includes(linkMarkup));
return linkRange;
}
},

_handleMouseover(event) {
if (this._canShowToolbar) {
let target = getEventTargetMatchingTag('a', event.target, this.get('container'));
if (target && target.isContentEditable) {
this._timeout = run.later(this, function () {
this._showToolbar(target);
}, DELAY);
}
}
},

_handleMouseout(event) {
this._cancelTimeouts();

if (this.get('showToolbar')) {
let toElement = event.toElement || event.relatedTarget;
if (toElement && !(toElement === this.element || toElement === this._target || toElement.closest(`#${this.elementId}`))) {
this.set('showToolbar', false);
}
}
},

_showToolbar(target) {
this._target = target;
this.set('url', target.href);
this.set('showToolbar', true);
run.schedule('afterRender', this, function () {
this._positionToolbar(target);
});
},

_cancelTimeouts() {
run.cancel(this._timeout);
if (this._elementObserver) {
this._elementObserver.cancel();
}
},

_positionToolbar(target) {
let containerRect = this.element.parentNode.getBoundingClientRect();
let targetRect = target.getBoundingClientRect();
let {width, height} = this.element.getBoundingClientRect();
let newPosition = {};

// targetRect is relative to the viewport so we need to subtract the
// container measurements to get a position relative to the container
newPosition = {
top: targetRect.top - containerRect.top - height - TOOLBAR_MARGIN + TOOLBAR_PADDING,
left: targetRect.left - containerRect.left + targetRect.width / 2 - width / 2,
right: null
};

// don't overflow left boundary
if (newPosition.left < 0) {
newPosition.left = 0;
}
// same for right boundary
if (newPosition.left + width > containerRect.width) {
newPosition.left = null;
newPosition.right = 0;
}

// update the toolbar position
this.setProperties(newPosition);
},

_addStyleElement(styles) {
let styleElement = document.createElement('style');
styleElement.id = `${this.elementId}-style`;
styleElement.innerHTML = `#${this.elementId} > ul:after { ${styles} }`;
document.head.appendChild(styleElement);
},

_removeStyleElement() {
let styleElement = document.querySelector(`#${this.elementId}-style`);
if (styleElement) {
styleElement.remove();
}
},

_addEventListener(element, type, listener) {
let boundListener = run.bind(this, listener);
element.addEventListener(type, boundListener);
this._eventListeners.push([element, type, boundListener]);
},

_removeAllEventListeners() {
this._eventListeners.forEach(([element, type, listener]) => {
element.removeEventListener(type, listener);
});
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@
editLink=(action "editLink")
}}

{{!-- pop-up link hover toolbar --}}
{{koenig-link-toolbar
editor=editor
container=element
linkRange=linkRange
selectedRange=selectedRange
editLink=(action "editLink")
}}

{{!-- pop-up link editing toolbar --}}
{{#if linkRange}}
{{koenig-link-input
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{{#if showToolbar}}
<ul class="bg-darkgrey inline-flex pa0 ma0 list br3 shadow-2 items-center relative white f-small fw3 tracked-2">
<li class="mw70 ma0 truncate">
<a href="{{url}}" class="dib dim-lite pa2 pr1 link white" target="_blank" rel="noopener noreferrer">{{url}}</a>
</li>
<li class="ma0">
<button
type="button"
title="Edit"
class="dib dim-lite pa1 link"
{{action "edit"}}
>
{{!-- TODO: get correct icon --}}
{{svg-jar "koenig/kg-thin-edit" class="stroke-white w4 h4 nudge-top--1"}}
</button>
</li>
<li class="ma0 lh-solid">
<button
type="button"
title="Remove"
class="dib dim-lite pa1 pr2 link"
{{action "remove"}}
>
{{!-- TODO: get correct icon --}}
{{svg-jar "koenig/kg-thin-delete" class="stroke-white w4 h4 nudge-top--1"}}
</button>
</li>
</ul>
{{/if}}
1 change: 1 addition & 0 deletions lib/koenig-editor/app/components/koenig-link-toolbar.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export {default} from 'koenig-editor/components/koenig-link-toolbar';
8 changes: 8 additions & 0 deletions lib/koenig-editor/public/icons/koenig/kg-thin-delete.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions lib/koenig-editor/public/icons/koenig/kg-thin-edit.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 24 additions & 0 deletions tests/integration/components/koenig-link-toolbar-test.js
Original file line number Diff line number Diff line change
@@ -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-toolbar', function () {
setupComponentTest('koenig-link-toolbar', {
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-toolbar}}
// template content
// {{/koenig-link-toolbar}}
// `);

this.render(hbs`{{koenig-link-toolbar}}`);
expect(this.$()).to.have.length(1);
});
});

0 comments on commit 07aba16

Please sign in to comment.