More manual DOM manipulations

This commit is contained in:
Chi Vinh Le
2018-03-23 17:03:57 +01:00
parent 246e3efe7f
commit cc66be033a
10 changed files with 450 additions and 63 deletions
@@ -3,17 +3,15 @@ import PropTypes from 'prop-types';
import styles from './RTE.css';
import cn from 'classnames';
import ContentEditable from 'react-contenteditable';
import Toolbar from './Toolbar';
import Toolbar from './components/Toolbar';
import { insertNewLine } from './lib/dom';
import API from './lib/api';
class Editor extends React.Component {
class RTE extends React.Component {
ref = null;
handleRef = ref => (this.ref = ref);
api = null;
buttonsRef = {};
get contentEditable() {
return this.ref.htmlEl;
}
createButtonRefHandler(key) {
return ref => {
if (ref) {
@@ -24,38 +22,46 @@ class Editor extends React.Component {
};
}
hasAncestor(tag) {
const sel = window.getSelection();
const range = sel.getRangeAt(0);
let cur = range.startContainer;
do {
if (cur.nodeName === tag) {
return true;
}
cur = cur.parentNode;
} while (cur);
return false;
}
forEachButton(callback) {
Object.keys(this.buttonsRef).map(k => callback(this.buttonsRef[k]));
}
handleChange = evt => {
handleChange = () => {
this.props.onChange({
text: this.ref.htmlEl.innerText,
html: evt.target.value,
html: this.ref.htmlEl.innerHTML,
});
};
handleRef = ref => (
(this.ref = ref), (this.api = new API(this.ref.htmlEl, this.handleChange))
);
handleSelectionChange = () => {
// console.log(window.getSelection().getRangeAt(0));
this.forEachButton(b => {
b.onSelectionChange && b.onSelectionChange();
});
};
handleSpecialEnter = () => {
let handled = false;
const sel = window.getSelection();
const range = sel.getRangeAt(0);
let container = range.startContainer;
while (!handled && container && container !== this.ref.htmlEl) {
this.forEachButton(b => {
if (!handled) {
handled = !!(b.onEnter && b.onEnter(container));
}
});
container = container.parentNode;
}
return handled;
};
handleClick = () => {
this.handleSelectionChange();
setTimeout(() => this.handleSelectionChange());
};
handleKeyDown = () => {
@@ -68,17 +74,25 @@ class Editor extends React.Component {
handleKeyPress = e => {
this.handleSelectionChange();
if (e.key === 'Enter' && !e.shiftKey) {
setTimeout(() => {
document.execCommand('outdent');
});
if (e.key === 'Enter') {
if (!e.shiftKey && this.handleSpecialEnter()) {
this.handleChange();
e.preventDefault();
return false;
}
insertNewLine(true);
this.handleChange();
e.preventDefault();
return false;
}
};
renderButtons() {
return this.props.buttons.map(b => {
return React.cloneElement(b, {
rte: this,
api: this.api,
ref: this.createButtonRefHandler(b.key),
});
});
@@ -115,11 +129,11 @@ class Editor extends React.Component {
}
}
Editor.defaultProps = {
RTE.defaultProps = {
buttons: [],
};
Editor.propTypes = {
RTE.propTypes = {
buttons: PropTypes.array,
inputId: PropTypes.string,
input: PropTypes.object,
@@ -132,4 +146,4 @@ Editor.propTypes = {
value: PropTypes.string,
};
export default Editor;
export default RTE;
@@ -1,24 +1,58 @@
import createToggle from '../factories/createToggle';
import { hasAncestor } from '../utils';
import bowser from 'bowser';
import {
findIntersectingTag,
insertNewLineAfterNode,
replaceSelection,
insertNodes,
getSelectedNodesExpanded,
outdentNode,
} from '../lib/dom';
const execCommand = () => {
if (hasAncestor('BLOCKQUOTE')) {
document.execCommand('outdent');
// TODO: select end of node.
function selectNode(node) {
const range = document.createRange();
const container = node.childNodes.length ? node.childNodes[0] : node;
range.setStart(container, 0);
range.setEnd(container, 0);
replaceSelection(range);
}
function execCommand() {
const bq = findIntersectingTag('BLOCKQUOTE');
if (bq) {
outdentNode(bq, true);
} else {
if (bowser.msie) {
document.execCommand('indent');
const node = document.createElement('blockquote');
const selectedNodes = getSelectedNodesExpanded();
if (selectedNodes.length) {
const firstNode = selectedNodes[0];
firstNode.parentNode.insertBefore(node, firstNode);
selectedNodes.forEach(n => {
node.appendChild(n);
});
selectNode(node);
} else {
document.execCommand('formatBlock', false, 'blockquote');
node.appendChild(document.createElement('br'));
insertNodes(node);
selectNode(node);
}
this.broadcastChange();
}
}
function syncState() {
return !!findIntersectingTag('BLOCKQUOTE');
}
const onEnter = node => {
if (node.tagName !== 'BLOCKQUOTE') {
return;
}
insertNewLineAfterNode(node, true);
return true;
};
const syncState = () => {
return hasAncestor('BLOCKQUOTE');
};
const Blockquote = createToggle(execCommand, syncState);
const Blockquote = createToggle(execCommand, syncState, { onEnter });
Blockquote.defaultProps = {
children: 'Blockquote',
@@ -1,25 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import Button from '../Button';
import Button from '../components/Button';
const createToggle = (execCommand, getCurrentState) => {
const createToggle = (execCommand, getCurrentState, { onEnter } = {}) => {
class Toggle extends React.Component {
state = {
active: false,
};
execCommand = () => execCommand.apply(this.props.api);
getCurrentState = () => getCurrentState.apply(this.props.api);
onEnter = (...args) => onEnter && onEnter.apply(this.props.api, args);
formatToggle = () => {
execCommand();
this.props.rte.contentEditable.focus();
this.execCommand();
};
handleClick = () => {
this.props.api.focus();
this.formatToggle();
this.syncState();
this.props.api.focus();
};
syncState = () => {
if (this.state.active !== getCurrentState()) {
if (this.state.active !== this.getCurrentState()) {
this.setState(state => ({
active: !state.active,
}));
@@ -46,7 +50,7 @@ const createToggle = (execCommand, getCurrentState) => {
}
Toggle.propTypes = {
rte: PropTypes.object,
api: PropTypes.object,
className: PropTypes.string,
title: PropTypes.string,
children: PropTypes.node,
@@ -0,0 +1,14 @@
import { selectionIsInside } from './dom';
export default class API {
constructor(contentEditable, onChange) {
this.contentEditable = contentEditable;
this.broadcastChange = onChange;
}
isSelectionInside() {
return selectionIsInside(this.contentEditable);
}
focus() {
this.contentEditable.focus();
}
}
@@ -0,0 +1,333 @@
export function findAncestorWithTag(node, tag) {
do {
if (node.nodeName === tag) {
return node;
}
node = node.parentNode;
} while (node);
return false;
}
export function findIntersectingTag(tag) {
const range = getSelectionRange();
if (!range) {
return null;
}
const ancestor = findAncestorWithTag(range.startContainer, tag);
if (ancestor) {
return ancestor;
}
const nodes = getSelectedChildren(range.commonAncestorContainer);
for (let i = 0; i < nodes.length; i++) {
if (nodes[i].tagName === tag) {
return nodes[i];
}
const query = nodes[i].querySelector && nodes[i].querySelector(tag);
if (query) {
return query;
}
}
return null;
}
export function indexOfChildNode(parent, child) {
for (let i = 0; i < parent.childNodes.length; i++) {
if (parent.childNodes[i] === child) {
return i;
}
}
return -1;
}
export function insertText(text) {
const selection = window.getSelection();
if (!selection.isCollapsed) {
document.execCommand('delete');
}
const range = selection.getRangeAt(0);
const offset = range.startOffset;
const container = range.startContainer;
if (container.nodeName === '#text') {
container.textContent =
container.textContent.slice(0, offset) +
text +
container.textContent.slice(offset);
const nextOffset = offset + text.length;
range.setStart(container, nextOffset);
range.setEnd(container, nextOffset);
} else {
const textNode = document.createTextNode(text);
container.insertBefore(textNode, container.childNodes[offset]);
}
}
export function insertNodes(...nodes) {
const selection = window.getSelection();
if (!selection.isCollapsed) {
document.execCommand('delete');
}
const range = selection.getRangeAt(0);
const offset = range.startOffset;
const container = range.startContainer;
if (container.nodeName === '#text') {
const startSlice = container.textContent.slice(0, offset);
const endSlice = container.textContent.slice(offset);
if (startSlice) {
nodes.splice(0, 0, document.createTextNode(startSlice));
}
if (endSlice) {
nodes.push(document.createTextNode(endSlice));
}
const parentNode = container.parentNode;
nodes.forEach(n => parentNode.insertBefore(n, container));
parentNode.removeChild(container);
} else {
let parentNode = container;
let nextSibling = container.childNodes[offset];
nodes.forEach(n => parentNode.insertBefore(n, nextSibling));
}
}
export function replaceSelection(range) {
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
export function isSelectionCollapsed() {
return window.getSelection().isCollapsed;
}
export function getSelectionRange() {
const selection = window.getSelection();
return selection.rangeCount ? selection.getRangeAt(0) : null;
}
function ensureEndMarker(node) {
if (
!node.lastChild ||
node.lastChild.tagName !== 'BR' ||
node.lastChild.className !== 'coral-rte-end-marker'
) {
const br = document.createElement('br');
br.className = 'coral-rte-end-marker';
node.appendChild(br);
}
}
export function selectionIsInside(node) {
const range = getSelectionRange();
return (
range &&
(node.contains(range.startContainer) || node.contains(range.endContainer))
);
}
export function appendNewLine(node, changeSelection) {
const el = document.createElement('br');
node.appendChild(el);
if (changeSelection) {
const offset = indexOfChildNode(node, el);
const range = document.createRange();
range.setStart(node, offset);
range.setEnd(node, offset);
replaceSelection(range);
}
}
export function insertNewLine(changeSelection) {
// Insert <br> node.
const el = document.createElement('br');
insertNodes(el);
// Calculate next selection.
const range = document.createRange();
if (el.nextSibling) {
const offset = indexOfChildNode(el.parentNode, el.nextSibling);
range.setStart(el.parentNode, offset);
range.setEnd(el.parentNode, offset);
} else {
// We need to add a <br> marker at the end, because we can't
// select the last <br>.
ensureEndMarker(el.parentNode);
const offset = el.parentNode.childNodes.length - 1;
range.setStart(el.parentNode, offset);
range.setEnd(el.parentNode, offset);
}
if (changeSelection) {
replaceSelection(range);
}
}
export function insertNewLineAfterNode(node, changeSelection) {
if (node.parentNode.lastChild === node) {
appendNewLine(node.parentNode, changeSelection);
} else {
if (changeSelection) {
const offset = indexOfChildNode(node.parentNode, node) + 1;
const range = document.createRange();
range.setStart(node.parentNode, offset);
range.setEnd(node.parentNode, offset);
replaceSelection(range);
}
insertNewLine();
}
}
export function getSelectedNode(container, offset) {
if (container.nodeName === '#text') {
return container;
}
return container.childNodes[offset];
}
export function isBlockElement(node) {
if (node.nodeName === '#text') {
return false;
}
return !window
.getComputedStyle(node)
.getPropertyValue('display')
.startsWith('inline');
}
export function findParentBlock(node) {
if (!node.parentNode) {
return null;
}
if (isBlockElement(node.parentNode)) {
return node.parentNode;
}
return findParentBlock(node.parentNode);
}
export function lastParentBeforeBlock(node) {
let child = node;
while (!isBlockElement(child.parentNode)) {
child = node.parentNode;
}
return child;
}
export function getLeftOfNode(node) {
let result = [];
let leftMost = node;
while (
leftMost.previousSibling &&
leftMost.previousSibling.tagName !== 'BR' &&
!isBlockElement(leftMost.previousSibling)
) {
result.splice(0, 0, leftMost.previousSibling);
leftMost = leftMost.previousSibling;
}
return result;
}
export function getRightOfNode(node) {
let result = [];
let cur = node;
while (
cur.nextSibling &&
cur.nextSibling.tagName !== 'BR' &&
!isBlockElement(cur.nextSibling)
) {
cur = cur.nextSibling;
result.push(cur);
}
if (cur.nextSibling && cur.nextSibling.tagName === 'BR') {
result.push(cur.nextSibling);
}
return result;
}
export function getWholeLine(node) {
if (isBlockElement(node)) {
return [node];
}
const child = lastParentBeforeBlock(node);
return [...getLeftOfNode(child), child, ...getRightOfNode(child)];
}
export function getSelectedLine() {
const range = getSelectionRange();
if (!range) {
return [];
}
const start = getSelectedNode(range.startContainer, range.startOffset);
return start ? getWholeLine(start) : [];
}
export function getSelectedNodesExpanded() {
const range = getSelectionRange();
if (!range) {
return [];
}
if (range.collapsed) {
return getSelectedLine();
}
let ancestor = range.commonAncestorContainer;
if (!isBlockElement(ancestor)) {
ancestor = findParentBlock(ancestor);
}
const result = getSelectedChildren(ancestor);
return [
...getLeftOfNode(result[0]),
...result,
...getRightOfNode(result[result.length - 1]),
];
}
export function getSelectedChildren(ancestor) {
const result = [];
const range = getSelectionRange();
if (!range) {
return result;
}
if (!range) {
return result;
}
const start = getSelectedNode(range.startContainer, range.startOffset);
const end = getSelectedNode(range.endContainer, range.endOffset);
let foundStart = false;
for (let i = 0; i < ancestor.childNodes.length; i++) {
const node = ancestor.childNodes[i];
if (!foundStart) {
if (node.contains(start)) {
foundStart = true;
}
}
if (foundStart) {
result.push(node);
if (node.contains(end)) {
break;
}
}
}
return result;
}
export function outdentNode(node, changeSelection) {
const parentNode = node.parentNode;
const offset = indexOfChildNode(parentNode, node);
while (node.childNodes.length) {
parentNode.insertBefore(node.childNodes[0], node);
}
parentNode.removeChild(node);
if (changeSelection) {
const range = document.createRange();
range.setStart(parentNode, offset);
range.setEnd(parentNode, offset);
replaceSelection(range);
}
}
@@ -1,12 +0,0 @@
export function hasAncestor(tag) {
const sel = window.getSelection();
const range = sel.getRangeAt(0);
let cur = range.startContainer;
do {
if (cur.nodeName === tag) {
return true;
}
cur = cur.parentNode;
} while (cur);
return false;
}