Skip to content

Instantly share code, notes, and snippets.

@zaydek-old
Created August 28, 2019 13:19
Show Gist options
  • Save zaydek-old/94765e1eacce8e4de1c6f5ad9861f3ee to your computer and use it in GitHub Desktop.
Save zaydek-old/94765e1eacce8e4de1c6f5ad9861f3ee to your computer and use it in GitHub Desktop.
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