mirror of
https://github.com/wassname/talk.git
synced 2026-07-01 16:55:12 +08:00
More manual DOM manipulations
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user