mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-02-24 23:48:13 -05:00
Koenig - Link hover toolbar
refs https://github.com/TryGhost/Ghost/issues/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
This commit is contained in:
parent
dcd1cc8061
commit
583b2e47d8
8 changed files with 314 additions and 0 deletions
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
});
|
|
@ -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
|
||||
|
|
|
@ -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}}
|
|
@ -0,0 +1 @@
|
|||
export {default} from 'koenig-editor/components/koenig-link-toolbar';
|
|
@ -0,0 +1,8 @@
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>
|
||||
kg-thin-delete
|
||||
</title>
|
||||
<g stroke="#FFF" fill="none" fill-rule="evenodd">
|
||||
<path stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" d="M1 4h13.5M13 4v9.5c0 1-.5 1.5-1.5 1.5H4c-1 0-1.5-.5-1.5-1.5V4M4.75 4V2.5c0-.414.146-.768.44-1.06.292-.294.646-.44 1.06-.44h3c1 0 1.5.5 1.5 1.5V4M6.25 6.75v5.5M9.25 6.75v5.5"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 461 B |
|
@ -0,0 +1,9 @@
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>
|
||||
kg-link-edit
|
||||
</title>
|
||||
<g stroke="#FFF" fill="none" fill-rule="evenodd">
|
||||
<path d="M5.255 13.644l-3.772.885.885-3.772 9.1-9.1c.455-.456 1.195-.456 1.65 0l1.238 1.237c.455.456.455 1.194 0 1.65l-9.101 9.1zM13.112 5.79l-2.881-2.896 2.881 2.896z" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5.402 13.497L2.514 10.61l2.888 2.887z" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 532 B |
|
@ -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);
|
||||
});
|
||||
});
|
Loading…
Add table
Reference in a new issue