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.
Koenig - Link creation/editing via formatting toolbar
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
1 parent
7fa52be
commit f0fe23d
Showing
9 changed files
with
387 additions
and
3 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
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
273 changes: 273 additions & 0 deletions
273
lib/koenig-editor/addon/components/koenig-link-input.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,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(); | ||
} | ||
} | ||
}); |
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
Oops, something went wrong.