Skip to content

Instantly share code, notes, and snippets.

@developit
Last active November 9, 2017 20:31
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save developit/502f9d754cafa0fed67979bbd740df5c to your computer and use it in GitHub Desktop.
Save developit/502f9d754cafa0fed67979bbd740df5c to your computer and use it in GitHub Desktop.
asdf

ZM Next: Rich Text Editor demo

Usage:

# clone + install
git clone https://gist.github.com/502f9d754cafa0fed67979bbd740df5c.git zmnext-rich-text-editor
cd zmnext-rich-text-editor
npm install

# development livereload server:
PORT=1337 npm start

# production server:
PORT=1337 NODE_ENV=production npm start
node_modules
/build
/*.log
import { Component } from 'preact';
import RichTextArea from './rich-text-area';
import './style';
const COMMANDS = [
['Bold', 'B'],
['Italic', 'i'],
['Underline', '⎁']
];
export default class App extends Component {
editorRef = c => {
this.editor = c;
};
prevent = e => {
e.preventDefault();
};
updateValue = e => {
this.setState({ value: e.value });
};
_commands = {};
command = name => this._commands[name] || (this._commands[name] = e => {
if (e) e.preventDefault();
this.editor.execCommand(name);
});
renderCommand = ([name, label]) => (
<button
active={this.editor && this.editor.queryCommandState(name)}
onMouseDown={this.prevent}
onTouchStart={this.prevent}
onClick={this.command(name)}
>
{label || name}
</button>
);
render({ }, { value }) {
return (
<div class="app">
<h1>Rich Text Editor Demo</h1>
<div class="toolbar">
{ COMMANDS.map(this.renderCommand) }
</div>
<RichTextArea
ref={this.editorRef}
onInput={this.updateValue}
onKeyDown={this.updateValue}
value={value}
/>
<hr />
<h2>Value:</h2>
<div dangerouslySetInnerHTML={{ __html: value }} />
</div>
);
}
}
{
"private": true,
"name": "zmnext-rich-text-editor",
"version": "0.0.0",
"license": "MIT",
"scripts": {
"start": "if-env NODE_ENV=production && npm run -s serve || npm run -s dev",
"build": "preact build",
"serve": "preact build && preact serve",
"dev": "preact watch",
"lint": "eslint src"
},
"eslintConfig": {
"extends": "eslint-config-synacor",
"rules": {
"react/no-danger": 0,
"eqeqeq": [1, "smart"]
}
},
"eslintIgnore": [
"build/*"
],
"devDependencies": {
"eslint": "^4.5.0",
"eslint-config-synacor": "^1.1.0",
"if-env": "^1.0.0",
"preact-cli": "^2.0.0"
},
"dependencies": {
"preact": "^8.2.1",
"preact-compat": "^3.17.0"
}
}
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}
/>
);
}
}
html, body {
background: #FBFBFF;
font: 14px/1.21 'Helvetica Neue', arial, sans-serif;
font-weight: 400;
}
.app {
max-width: 800px;
margin: auto;
}
h1 {
text-align: center;
}
.toolbar {
position: relative;
background: #DDD;
padding: 2px;
z-index: 1;
}
.toolbar button {
display: inline-block;
padding: 10px;
min-width: 3em;
background: #FFF;
border: 1px solid #CCC;
border-radius: 3px;
font-size: 100%;
color: #000;
}
.toolbar button[active] {
font-weight: bold;
border-color: #AAA;
box-shadow: inset 0 2px 7px rgba(0,0,0,0.3);
}
rich-text-area {
display: block;
/* margin: 20px; */
padding: 10px;
background: #FFF;
border: 1px solid #DDD;
min-height: 300px;
outline: none;
}
rich-text-area:focus {
box-shadow: 0 0 5px rgba(100,200,255,0.7);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment