|
import { h, Component } from 'preact'; |
|
|
|
function moveChildren(oldParent, newParent, skip) { |
|
let child = oldParent.firstChild; |
|
while (child) { |
|
let next = child.nextSibling; |
|
if (child !== skip) { |
|
newParent.appendChild(child); |
|
} |
|
child = next; |
|
} |
|
} |
|
|
|
export default class RichTextArea extends Component { |
|
// Creates a safe wrapper around a document command |
|
createCommandProxy = type => (...args) => { |
|
try { |
|
return document[type](...args); |
|
} |
|
catch (err) { } |
|
}; |
|
|
|
execCommand = this.createCommandProxy('execCommand'); |
|
queryCommandState = this.createCommandProxy('queryCommandState'); |
|
queryCommandValue = this.createCommandProxy('queryCommandValue'); |
|
|
|
getBase = () => this.base; |
|
|
|
focus = () => { |
|
this.base.focus(); |
|
|
|
let selection = getSelection(), |
|
range = selection.rangeCount && selection.getRangeAt(0), |
|
parent = range ? range.commonAncestorContainer : selection.anchorNode; |
|
if (parent == null || (parent !== this.base && !this.base.contains(parent))) { |
|
selection.removeAllRanges(); |
|
range = document.createRange(); |
|
range.setStartAfter(this.base.lastChild); |
|
range.collapse(true); |
|
selection.addRange(range); |
|
this.base.focus(); |
|
} |
|
}; |
|
|
|
blur = () => { |
|
this.base.blur(); |
|
}; |
|
|
|
handleEvent = e => { |
|
let type = 'on' + e.type.toLowerCase(); |
|
this.eventValue = this.base.innerHTML; |
|
this.eventValueTime = Date.now(); |
|
this.updatePlaceholder(); |
|
e.value = this.eventValue; |
|
for (let i in this.props) { |
|
if (this.props.hasOwnProperty(i) && i.toLowerCase() === type) { |
|
this.props[i](e); |
|
} |
|
} |
|
}; |
|
|
|
updatePlaceholder() { |
|
clearTimeout(this.updatePlaceholderTimer); |
|
this.updatePlaceholderTimer = setTimeout(this.updatePlaceholderSync, 100); |
|
} |
|
|
|
updatePlaceholderSync = () => { |
|
if (!this.base.textContent) { |
|
this.base.setAttribute('data-empty', ''); |
|
} |
|
else if (this.base.hasAttribute('data-empty')) { |
|
this.base.removeAttribute('data-empty'); |
|
} |
|
}; |
|
|
|
handlePaste = e => { |
|
if (this.props.onPaste) this.props.onPaste(e); |
|
this.scheduleCleanup(); |
|
}; |
|
|
|
scheduleCleanup() { |
|
if (this.cleanupTimer != null) return; |
|
this.cleanupTimer = setTimeout(this.cleanupSync); |
|
} |
|
|
|
cleanupSync = () => { |
|
// simulated. |
|
let selection = window.getSelection(); |
|
let range = selection.rangeCount && selection.getRangeAt(0); |
|
let sentinel = document.createElement('span'); |
|
sentinel.setAttribute('data-contains-cursor', 'true'); |
|
if (range) range.insertNode(sentinel); |
|
|
|
clearTimeout(this.cleanupTimer); |
|
this.cleanupTimer = null; |
|
|
|
let body = document.createElement('body'); |
|
let dummy = document.createTextNode(''); |
|
body.appendChild(dummy); |
|
|
|
moveChildren(this.base, body); |
|
|
|
let lastChild = body.lastChild; |
|
|
|
moveChildren(body, this.base, dummy); |
|
|
|
this.base.focus(); |
|
selection = window.getSelection(); |
|
selection.removeAllRanges(); |
|
range = document.createRange(); |
|
let removeSentinel = true; |
|
|
|
// if the sentinel got replaced during sanitization, find its replacement: |
|
if (sentinel == null || !this.base.contains(sentinel)) { |
|
sentinel = this.base.querySelector('[data-contains-cursor]'); |
|
} |
|
|
|
if (sentinel == null) { |
|
removeSentinel = false; |
|
sentinel = this.base.lastChild || lastChild; |
|
} |
|
|
|
if (sentinel != null) { |
|
range.setStartAfter(sentinel); |
|
range.collapse(true); |
|
selection.addRange(range); |
|
if (removeSentinel) sentinel.parentNode.removeChild(sentinel); |
|
} |
|
}; |
|
|
|
setContent(html) { |
|
this.base.innerHTML = this.eventValue = html; |
|
} |
|
|
|
componentDidMount() { |
|
if (this.props.value) { |
|
this.setContent(this.props.value); |
|
} |
|
} |
|
|
|
shouldComponentUpdate({ value }) { |
|
if (value !== this.props.value && value !== this.eventValue) { |
|
this.setContent(value); |
|
this.updatePlaceholder(); |
|
} |
|
return false; |
|
} |
|
|
|
componentWillUnmount() { |
|
let child; |
|
while ((child = this.base.lastChild)) { |
|
this.base.removeChild(child); |
|
} |
|
} |
|
|
|
render({ children, value, ...props }) { |
|
return ( |
|
<rich-text-area |
|
{...props} |
|
contentEditable |
|
onInput={this.handleEvent} |
|
onKeyDown={this.handleEvent} |
|
onKeyUp={this.handleEvent} |
|
onChange={this.handleEvent} |
|
onPaste={this.handlePaste} |
|
/> |
|
); |
|
} |
|
} |