Created
August 28, 2019 13:19
-
-
Save zaydek-old/94765e1eacce8e4de1c6f5ad9861f3ee to your computer and use it in GitHub Desktop.
This file contains hidden or 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
import PropTypes from "prop-types" | |
import React from "react" | |
import { useInterval } from "react-use" | |
import * as globals from "./globals" | |
import * as traverseDOM from "./traverseDOM" | |
import lex from "./lexer" | |
// // TODO: Refactor to hook with and use `useDebounce`. | |
// function scrollIntoView() { | |
// let { focusNode } = document.getSelection() // Use `let`. | |
// // `scrollIntoView` expects an element node. | |
// if (traverseDOM.isBreakNode(focusNode) || traverseDOM.isTextNode(focusNode)) { | |
// focusNode = focusNode.parentNode | |
// } | |
// focusNode.scrollIntoView({ /* behavior: "smooth", */ block: "nearest" }) | |
// } | |
// +----------------------------------------+ | |
// | | Apple | Windows | Linux | | |
// |------|----------|---------|------------| | |
// | Undo | cmd-z | ctrl-z | ctrl-z | | |
// +----------------------------------------+ | |
function detectUndo(e) { | |
return (globals.isAppleOS ? e.metaKey : e.ctrlKey) && !e.shiftKey && e.key.toLowerCase() === "z" | |
} | |
// +----------------------------------------+ | |
// | | Apple | Windows | Linux | | |
// |------|----------|---------|------------| | |
// | Redo | cmd-sh-z | ctrl-y | ctrl-sh-z | | |
// +----------------------------------------+ | |
function detectRedo(e) { | |
return ((globals.isAppleOS ? e.metaKey : e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "z") || (!globals.isAppleOS && e.ctrlKey && !e.shiftKey && e.key.toLowerCase() === "y") | |
} | |
function Editor(props) { | |
const ref = React.useRef(null) | |
// `atLineEnd` returns whether the cursor is at the end of | |
// the line. The selection API doesn’t allow this behavior | |
// (that I’m aware of) so instead we defer to native | |
// browser behavior. | |
const atLineEnd = React.useRef(false) | |
// // `didDragAndDrop` returns whether the user dragged and | |
// // dropped on the editor. This is needed because the | |
// // editor doesn’t interfere with the user’s selection. | |
// const didDragAndDrop = React.useRef(false) | |
const didMountReadOnly = React.useRef(false) | |
React.useLayoutEffect( | |
React.useCallback(() => { | |
if (didMountReadOnly.current) { | |
didMountReadOnly.current = true | |
return | |
} | |
// Guard setting the cursor when the editor is | |
// read-only. | |
if (props.readOnly) { | |
// Fix for Safari: when toggling read-only, the | |
// editor still has focus and is editable. | |
document.activeElement.blur() | |
return | |
} | |
const { pos1, pos2 } = props.state | |
const { node: node1, offset: offset1 } = traverseDOM.findNode(ref.current, pos1.absolute) | |
const { node: node2, offset: offset2 } = traverseDOM.findNode(ref.current, pos2.absolute) | |
const range = document.createRange() | |
range.setStart(node1, offset1) | |
range.setEnd (node2, offset2) | |
const selection = document.getSelection() | |
selection.removeAllRanges() | |
selection.addRange(range) | |
}, [props.readOnly, props.state]), | |
[props.readOnly]) | |
const didMountPos = React.useRef(false) | |
React.useLayoutEffect(() => { | |
if (!didMountPos.current) { | |
didMountPos.current = true | |
return | |
} | |
// Guard setting the cursor if there’s no selection. | |
// Correcting the user’s selection when the cursor isn’t | |
// collapsed is known to cause problems. | |
const { pos1, pos2 } = props.state | |
if (pos1.absolute !== pos2.absolute || atLineEnd.current) { | |
atLineEnd.current = false | |
return | |
} | |
const { node, offset } = traverseDOM.findNode(ref.current, pos1.absolute) | |
const range = document.createRange() | |
range.setStart(node, offset) | |
range.collapse(true) | |
const selection = document.getSelection() | |
selection.removeAllRanges() | |
selection.addRange(range) | |
}, [props.state]) | |
useInterval(() => { | |
props.dispatch.storeUndo() | |
}, 1e3) | |
// getPos gets the positions from the DOM. | |
const getPos = () => { | |
if (document.activeElement !== ref.current) { | |
return null | |
} | |
const { anchorNode, anchorOffset, focusNode, focusOffset } = document.getSelection() | |
const pos1 = traverseDOM.findPos(ref.current, { node: anchorNode, offset: anchorOffset }) | |
const pos2 = traverseDOM.findPos(ref.current, { node: focusNode , offset: focusOffset }) | |
return { pos1, pos2 } | |
} | |
const handleSelect = e => { | |
if (props.readOnly || document.activeElement !== ref.current) { | |
return | |
} | |
const { data } = props.state | |
const { pos1, pos2 } = getPos() | |
props.dispatch.setState(data, pos1, pos2) | |
} | |
const handleKeyPress = e => { | |
// Fix for Safari: Safari registers select all e.g. | |
// `cmd-a` as a key press event instead of as a | |
// key down event. | |
if ((globals.isAppleOS ? e.metaKey : e.ctrlKey) && e.key === "a") { | |
return | |
} | |
e.preventDefault() | |
props.dispatch.prune() | |
const { pos1, pos2 } = props.state | |
const userSelection = props.state.data.slice(pos1.absolute, pos2.absolute) | |
props.dispatch[userSelection.indexOf("\n") === -1 ? "insertText" : "insertNode"](e.key) | |
// scrollIntoView() | |
} | |
const handleKeyDown = e => { | |
if ((globals.isAppleOS ? e.metaKey : e.ctrlKey) && e.key === "ArrowRight") { | |
atLineEnd.current = true | |
return | |
} | |
// +----------------------------------------+ | |
// | | Apple | Windows | Linux | | |
// |------|----------|---------|------------| | |
// | Undo | cmd-z | ctrl-z | ctrl-z | | |
// | Redo | cmd-sh-z | ctrl-y | ctrl-sh-z | | |
// +----------------------------------------+ | |
if (detectUndo(e)) { | |
e.preventDefault() | |
props.dispatch.undo() | |
// scrollIntoView() | |
return | |
} else if (detectRedo(e)) { | |
e.preventDefault() | |
props.dispatch.redo() | |
// scrollIntoView() | |
return | |
} | |
const [ent, delL, delR] = [e.key === "Enter", e.key === "Backspace", e.ctrlKey && e.key === "d"] | |
if (!ent && !delL && !delR) { | |
return | |
} | |
switch (true) { | |
case ent: | |
e.preventDefault() | |
props.dispatch.prune() | |
props.dispatch.insertNode("\n") | |
// scrollIntoView() | |
return | |
case delL || delR: | |
// Guard deleting the insertion point node (without | |
// a selection). | |
const { data, pos1, pos2 } = props.state | |
const isCollapsed = pos1.absolute === pos2.absolute // Convenience variable. | |
if (isCollapsed && ((delL && !pos1.absolute) || (delR && pos2.absolute === data.length))) { | |
e.preventDefault() | |
// Don’t prune. | |
return | |
} | |
// Guard deleting a node (with or without a | |
// selection). | |
// | |
// The first branch is a fix for Firefox: Firefox | |
// selects the root node instead its children when | |
// selecting all e.g. `cmd-a`. | |
if ((!pos1.absolute && pos2.absolute === data.length) || pos1.index !== pos2.index || (isCollapsed && ((delL && data[pos1.absolute - 1] === "\n") || (delR && data[pos1.absolute] === "\n")))) { | |
e.preventDefault() | |
props.dispatch.prune() | |
const trimL = isCollapsed && delL ? 1 : 0 | |
const trimR = isCollapsed && delR ? 1 : 0 | |
props.dispatch.insertNode("", trimL, trimR) | |
// scrollIntoView() | |
return | |
} | |
// Defer to `handleInput`. | |
return | |
default: | |
return | |
} | |
} | |
const handleInput = e => { | |
// // Guard drag and drop: step 1. delete the selection. | |
// if (e.nativeEvent.inputType === "deleteByDrag") { | |
// const { pos1, pos2 } = props.state | |
// old.current = { pos1, pos2 } | |
// return | |
// // Guard drag and drop: step 2. insert the selection. | |
// } else if (e.nativeEvent.inputType === "insertFromDrop") { | |
// const { pos1, pos2 } = getPos() | |
// // props.dispatch.prune() | |
// props.dispatch.deleteAndInsert(old.current.pos1, old.current.pos2, pos1, pos2) | |
// // old.current = null | |
// // scrollIntoView() | |
// return | |
// } | |
props.dispatch.prune() | |
if (e.nativeEvent.isComposing) { | |
return | |
} | |
const data = traverseDOM.readRoot(ref.current) | |
const { pos1, pos2 } = getPos() | |
props.dispatch.setState(data, pos1, pos2) | |
// scrollIntoView() | |
} | |
const handleCompositionEnd = e => { | |
// Don’t prune. | |
const data = traverseDOM.readRoot(ref.current) | |
const { pos1, pos2 } = getPos() | |
props.dispatch.setState(data, pos1, pos2) | |
} | |
const handleCut = e => { | |
if (props.readOnly) { | |
// Defer to native browser behavior. | |
return | |
} | |
e.preventDefault() | |
const { pos1, pos2 } = props.state | |
if (pos1.absolute === pos2.absolute) { | |
// Idempotent if there’s nothing to cut. | |
return | |
} | |
props.dispatch.prune() | |
const userSelection = props.state.data.slice(pos1.absolute, pos2.absolute) | |
e.clipboardData.setData("text/plain", userSelection) | |
props.dispatch[userSelection.indexOf("\n") === -1 ? "insertText" : "insertNode"]("") | |
// scrollIntoView() | |
} | |
const handleCopy = e => { | |
if (props.readOnly) { | |
// Defer to native browser behavior. | |
return | |
} | |
e.preventDefault() | |
const { pos1, pos2 } = props.state | |
if (pos1.absolute === pos2.absolute) { | |
// Idempotent if there’s nothing to copy. | |
return | |
} | |
// Don’t prune. | |
const userSelection = props.state.data.slice(pos1.absolute, pos2.absolute) | |
e.clipboardData.setData("text/plain", userSelection) | |
} | |
const handlePaste = e => { | |
if (props.readOnly) { | |
// Defer to native browser behavior. | |
return | |
} | |
e.preventDefault() | |
const userData = e.clipboardData.getData("text/plain") | |
if (!userData) { | |
// Idempotent if there’s nothing to paste. | |
return | |
} | |
props.dispatch.prune() | |
const { pos1, pos2 } = props.state | |
const userSelection = props.state.data.slice(pos1.absolute, pos2.absolute) | |
props.dispatch[userData.indexOf("\n") === -1 && userSelection.indexOf("\n") === -1 ? "insertText" : "insertNode"](userData) | |
// scrollIntoView() | |
} | |
return ( | |
React.createElement( | |
"article", | |
{ | |
ref, | |
contentEditable: !props.readOnly || null, | |
suppressContentEditableWarning: !props.readOnly || null, | |
spellCheck: !props.readOnly || null, | |
style: { caretColor: "hsl(var(--blue-500))" }, | |
onSelect: handleSelect, | |
onKeyPress: handleKeyPress, | |
onKeyDown: handleKeyDown, | |
onInput: handleInput, | |
onCompositionEnd: handleCompositionEnd, | |
onCut: handleCut, | |
onCopy: handleCopy, | |
onPaste: handlePaste | |
}, | |
lex(props.state.data).map(({ Component, children }, index) => ( | |
<Component key={props.state.keys[index]} | |
readOnly={props.readOnly} reactKey={props.state.keys[index]} children={ | |
!children ? <span><br /></span> : children | |
} | |
/> | |
)) | |
) | |
) | |
} | |
Editor.propTypes = { | |
readOnly: PropTypes.bool.isRequired, | |
state: PropTypes.object.isRequired, | |
dispatch: PropTypes.object.isRequired | |
} | |
export default Editor |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment