This repository has been archived by the owner on Nov 28, 2022. It is now read-only.
-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
1 parent
2f6132e
commit 07aba16
Showing
8 changed files
with
314 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
233 changes: 233 additions & 0 deletions
233
lib/koenig-editor/addon/components/koenig-link-toolbar.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
} | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
29 changes: 29 additions & 0 deletions
29
lib/koenig-editor/addon/templates/components/koenig-link-toolbar.hbs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export {default} from 'koenig-editor/components/koenig-link-toolbar'; |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |