· 6 years ago · Sep 08, 2019, 11:06 PM
1import PropTypes from "prop-types"
2import React from "react"
3import { useInterval } from "react-use"
4
5import * as globals from "./globals"
6import * as traverseDOM from "./traverseDOM"
7import lex from "./lexer"
8
9// // TODO: Refactor to hook with and use `useDebounce`.
10// function scrollIntoView() {
11// let { focusNode } = document.getSelection() // Use `let`.
12// // `scrollIntoView` expects an element node.
13// if (traverseDOM.isBreakNode(focusNode) || traverseDOM.isTextNode(focusNode)) {
14// focusNode = focusNode.parentNode
15// }
16// focusNode.scrollIntoView({ /* behavior: "smooth", */ block: "nearest" })
17// }
18
19// +----------------------------------------+
20// | | Apple | Windows | Linux |
21// |------|----------|---------|------------|
22// | Undo | cmd-z | ctrl-z | ctrl-z |
23// +----------------------------------------+
24function detectUndo(e) {
25 return (globals.isAppleOS ? e.metaKey : e.ctrlKey) && !e.shiftKey && e.key.toLowerCase() === "z"
26}
27
28// +----------------------------------------+
29// | | Apple | Windows | Linux |
30// |------|----------|---------|------------|
31// | Redo | cmd-sh-z | ctrl-y | ctrl-sh-z |
32// +----------------------------------------+
33function detectRedo(e) {
34 return ((globals.isAppleOS ? e.metaKey : e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "z") || (!globals.isAppleOS && e.ctrlKey && !e.shiftKey && e.key.toLowerCase() === "y")
35}
36
37function Editor(props) {
38 const ref = React.useRef(null)
39
40 // `atLineEnd` returns whether the cursor is at the end of
41 // the line. The selection API doesn’t allow this behavior
42 // (that I’m aware of) so instead we defer to native
43 // browser behavior.
44 const atLineEnd = React.useRef(false)
45
46 // // `didDragAndDrop` returns whether the user dragged and
47 // // dropped on the editor. This is needed because the
48 // // editor doesn’t interfere with the user’s selection.
49 // const didDragAndDrop = React.useRef(false)
50
51 const didMountReadOnly = React.useRef(false)
52 React.useLayoutEffect(
53 React.useCallback(() => {
54 if (didMountReadOnly.current) {
55 didMountReadOnly.current = true
56 return
57 }
58 // Guard setting the cursor when the editor is
59 // read-only.
60 if (props.readOnly) {
61 // Fix for Safari: when toggling read-only, the
62 // editor still has focus and is editable.
63 document.activeElement.blur()
64 return
65 }
66 const { pos1, pos2 } = props.state
67 const { node: node1, offset: offset1 } = traverseDOM.findNode(ref.current, pos1.absolute)
68 const { node: node2, offset: offset2 } = traverseDOM.findNode(ref.current, pos2.absolute)
69 const range = document.createRange()
70 range.setStart(node1, offset1)
71 range.setEnd (node2, offset2)
72 const selection = document.getSelection()
73 selection.removeAllRanges()
74 selection.addRange(range)
75 }, [props.readOnly, props.state]),
76 [props.readOnly])
77
78 const didMountPos = React.useRef(false)
79 React.useLayoutEffect(() => {
80 if (!didMountPos.current) {
81 didMountPos.current = true
82 return
83 }
84 // Guard setting the cursor if there’s no selection.
85 // Correcting the user’s selection when the cursor isn’t
86 // collapsed is known to cause problems.
87 const { pos1, pos2 } = props.state
88 if (pos1.absolute !== pos2.absolute || atLineEnd.current) {
89 atLineEnd.current = false
90 return
91 }
92 const { node, offset } = traverseDOM.findNode(ref.current, pos1.absolute)
93 const range = document.createRange()
94 range.setStart(node, offset)
95 range.collapse(true)
96 const selection = document.getSelection()
97 selection.removeAllRanges()
98 selection.addRange(range)
99 }, [props.state])
100
101 useInterval(() => {
102 props.dispatch.storeUndo()
103 }, 1e3)
104
105 // getPos gets the positions from the DOM.
106 const getPos = () => {
107 if (document.activeElement !== ref.current) {
108 return null
109 }
110 const { anchorNode, anchorOffset, focusNode, focusOffset } = document.getSelection()
111 const pos1 = traverseDOM.findPos(ref.current, { node: anchorNode, offset: anchorOffset })
112 const pos2 = traverseDOM.findPos(ref.current, { node: focusNode , offset: focusOffset })
113 return { pos1, pos2 }
114 }
115
116 const handleSelect = e => {
117 if (props.readOnly || document.activeElement !== ref.current) {
118 return
119 }
120 const { data } = props.state
121 const { pos1, pos2 } = getPos()
122 props.dispatch.setState(data, pos1, pos2)
123 }
124
125 const handleKeyPress = e => {
126 // Fix for Safari: Safari registers select all e.g.
127 // `cmd-a` as a key press event instead of as a
128 // key down event.
129 if ((globals.isAppleOS ? e.metaKey : e.ctrlKey) && e.key === "a") {
130 return
131 }
132 e.preventDefault()
133 props.dispatch.prune()
134 const { pos1, pos2 } = props.state
135 const userSelection = props.state.data.slice(pos1.absolute, pos2.absolute)
136 props.dispatch[userSelection.indexOf("\n") === -1 ? "insertText" : "insertNode"](e.key)
137 // scrollIntoView()
138 }
139
140 const handleKeyDown = e => {
141 if ((globals.isAppleOS ? e.metaKey : e.ctrlKey) && e.key === "ArrowRight") {
142 atLineEnd.current = true
143 return
144 }
145
146 // +----------------------------------------+
147 // | | Apple | Windows | Linux |
148 // |------|----------|---------|------------|
149 // | Undo | cmd-z | ctrl-z | ctrl-z |
150 // | Redo | cmd-sh-z | ctrl-y | ctrl-sh-z |
151 // +----------------------------------------+
152 if (detectUndo(e)) {
153 e.preventDefault()
154 props.dispatch.undo()
155 // scrollIntoView()
156 return
157 } else if (detectRedo(e)) {
158 e.preventDefault()
159 props.dispatch.redo()
160 // scrollIntoView()
161 return
162 }
163
164 const [ent, delL, delR] = [e.key === "Enter", e.key === "Backspace", e.ctrlKey && e.key === "d"]
165 if (!ent && !delL && !delR) {
166 return
167 }
168 switch (true) {
169 case ent:
170 e.preventDefault()
171 props.dispatch.prune()
172 props.dispatch.insertNode("\n")
173 // scrollIntoView()
174 return
175 case delL || delR:
176 // Guard deleting the insertion point node (without
177 // a selection).
178 const { data, pos1, pos2 } = props.state
179 const isCollapsed = pos1.absolute === pos2.absolute // Convenience variable.
180 if (isCollapsed && ((delL && !pos1.absolute) || (delR && pos2.absolute === data.length))) {
181 e.preventDefault()
182 // Don’t prune.
183 return
184 }
185 // Guard deleting a node (with or without a
186 // selection).
187 //
188 // The first branch is a fix for Firefox: Firefox
189 // selects the root node instead its children when
190 // selecting all e.g. `cmd-a`.
191 if ((!pos1.absolute && pos2.absolute === data.length) || pos1.index !== pos2.index || (isCollapsed && ((delL && data[pos1.absolute - 1] === "\n") || (delR && data[pos1.absolute] === "\n")))) {
192 e.preventDefault()
193 props.dispatch.prune()
194 const trimL = isCollapsed && delL ? 1 : 0
195 const trimR = isCollapsed && delR ? 1 : 0
196 props.dispatch.insertNode("", trimL, trimR)
197 // scrollIntoView()
198 return
199 }
200 // Defer to `handleInput`.
201 return
202
203 default:
204 return
205 }
206 }
207
208 const handleInput = e => {
209 // // Guard drag and drop: step 1. delete the selection.
210 // if (e.nativeEvent.inputType === "deleteByDrag") {
211 // const { pos1, pos2 } = props.state
212 // old.current = { pos1, pos2 }
213 // return
214 // // Guard drag and drop: step 2. insert the selection.
215 // } else if (e.nativeEvent.inputType === "insertFromDrop") {
216 // const { pos1, pos2 } = getPos()
217 // // props.dispatch.prune()
218 // props.dispatch.deleteAndInsert(old.current.pos1, old.current.pos2, pos1, pos2)
219 // // old.current = null
220 // // scrollIntoView()
221 // return
222 // }
223
224 props.dispatch.prune()
225 if (e.nativeEvent.isComposing) {
226 return
227 }
228 const data = traverseDOM.readRoot(ref.current)
229 const { pos1, pos2 } = getPos()
230 props.dispatch.setState(data, pos1, pos2)
231 // scrollIntoView()
232 }
233
234 const handleCompositionEnd = e => {
235 // Don’t prune.
236 const data = traverseDOM.readRoot(ref.current)
237 const { pos1, pos2 } = getPos()
238 props.dispatch.setState(data, pos1, pos2)
239 }
240
241 const handleCut = e => {
242 if (props.readOnly) {
243 // Defer to native browser behavior.
244 return
245 }
246 e.preventDefault()
247 const { pos1, pos2 } = props.state
248 if (pos1.absolute === pos2.absolute) {
249 // Idempotent if there’s nothing to cut.
250 return
251 }
252 props.dispatch.prune()
253 const userSelection = props.state.data.slice(pos1.absolute, pos2.absolute)
254 e.clipboardData.setData("text/plain", userSelection)
255 props.dispatch[userSelection.indexOf("\n") === -1 ? "insertText" : "insertNode"]("")
256 // scrollIntoView()
257 }
258
259 const handleCopy = e => {
260 if (props.readOnly) {
261 // Defer to native browser behavior.
262 return
263 }
264 e.preventDefault()
265 const { pos1, pos2 } = props.state
266 if (pos1.absolute === pos2.absolute) {
267 // Idempotent if there’s nothing to copy.
268 return
269 }
270 // Don’t prune.
271 const userSelection = props.state.data.slice(pos1.absolute, pos2.absolute)
272 e.clipboardData.setData("text/plain", userSelection)
273 }
274
275 const handlePaste = e => {
276 if (props.readOnly) {
277 // Defer to native browser behavior.
278 return
279 }
280 e.preventDefault()
281 const userData = e.clipboardData.getData("text/plain")
282 if (!userData) {
283 // Idempotent if there’s nothing to paste.
284 return
285 }
286 props.dispatch.prune()
287 const { pos1, pos2 } = props.state
288 const userSelection = props.state.data.slice(pos1.absolute, pos2.absolute)
289 props.dispatch[userData.indexOf("\n") === -1 && userSelection.indexOf("\n") === -1 ? "insertText" : "insertNode"](userData)
290 // scrollIntoView()
291 }
292
293 return (
294 React.createElement(
295 "article",
296 {
297 ref,
298
299 contentEditable: !props.readOnly || null,
300 suppressContentEditableWarning: !props.readOnly || null,
301 spellCheck: !props.readOnly || null,
302
303 style: { caretColor: "hsl(var(--blue-500))" },
304
305 onSelect: handleSelect,
306 onKeyPress: handleKeyPress,
307 onKeyDown: handleKeyDown,
308 onInput: handleInput,
309 onCompositionEnd: handleCompositionEnd,
310 onCut: handleCut,
311 onCopy: handleCopy,
312 onPaste: handlePaste
313 },
314 lex(props.state.data).map(({ Component, children }, index) => (
315 <Component key={props.state.keys[index]}
316 readOnly={props.readOnly} reactKey={props.state.keys[index]} children={
317 !children ? <span><br /></span> : children
318 }
319 />
320 ))
321 )
322 )
323}
324
325Editor.propTypes = {
326 readOnly: PropTypes.bool.isRequired,
327 state: PropTypes.object.isRequired,
328 dispatch: PropTypes.object.isRequired
329}
330
331export default Editor