· 6 years ago · Sep 03, 2019, 06:16 PM
1import React from "react"
2import PropTypes from "prop-types"
3
4import useMethods from "use-methods"
5
6import { Title } from "./Title"
7
8// `isBreakNode` checks whether a node is `<br />` OR
9// `<span><br /></span>`.
10function isBreakNode(node) {
11 const isBreak = (
12 node.nodeType === Node.ELEMENT_NODE && (
13 node.nodeName === "BR" || (
14 node.nodeName === "SPAN" &&
15 node.childNodes.length === 1 &&
16 node.childNodes[0].nodeType === Node.ELEMENT_NODE &&
17 node.childNodes[0].nodeName === "BR"
18 )
19 )
20 )
21 return isBreak
22}
23
24function isTextNode(node) {
25 return node.nodeType === Node.TEXT_NODE
26}
27
28function readNode(node) {
29 return isBreakNode(node) ? "" : node.nodeValue
30}
31
32// `findNode` finds the node and its offset from an absolute
33// position.
34function findNode(root, pos) {
35 const node = {
36 node: null,
37 offset: 0
38 }
39 ;(function recurse(startNode) {
40 for (const child of startNode.childNodes) {
41 if (isBreakNode(child) || isTextNode(child)) {
42 const length = readNode(child).length
43 if (pos - length <= 0) {
44 Object.assign(node, { node: child, offset: pos })
45 return true
46 }
47 pos -= length
48 } else {
49 if (recurse(child)) {
50 return true
51 }
52 if (child.parentNode === root && child.nextSibling) {
53 pos--
54 }
55 }
56 }
57 return false
58 })(root)
59 return node
60}
61
62// `findPos` finds the absolute position, relative position,
63// and block index of a node and its offset.
64function findPos(root, { node, offset }) {
65 const pos = {
66 absolute: 0,
67 index: 0,
68 offset: 0
69 }
70 // Guard `<br />`: the selection API sometimes selects the
71 // parent node instead of `<br />`.
72 if (node.nodeType === Node.ELEMENT_NODE && !isBreakNode(node)) {
73 node = node.childNodes[offset]
74 offset = 0
75 }
76 ;(function recurse(startNode) {
77 for (const child of startNode.childNodes) {
78 if (isBreakNode(child) || isTextNode(child)) {
79 if (child === node) {
80 pos.absolute += offset
81 pos.offset = offset
82 return true
83 }
84 pos.absolute += readNode(child).length
85 } else {
86 if (recurse(child)) {
87 return true
88 }
89 if (child.parentNode === root && child.nextSibling) {
90 pos.absolute ++
91 pos.index++
92 }
93 }
94 }
95 return false
96 })(root)
97 return pos
98}
99
100// function editorMethods(state) {
101// const methods = {
102// // updatePos updates `pos1` and `pos2`, generally from
103// // the result of `findPos`.
104// updatePos(pos1, pos2) {
105// const min = pos1.abs < pos2.abs ? pos1 : pos2
106// const max = pos1.abs < pos2.abs ? pos2 : pos1
107// Object.assign(state.pos1, min)
108// Object.assign(state.pos2, max)
109// },
110// // `resolveSelection` resolves the selection’s start and
111// // end blocks, merging them and dropping intermediary
112// // blocks if necessary.
113// resolveSelection() {
114// if (state.pos1.index === state.pos2.index) {
115// return
116// }
117// const { start, lhs, rhs } = getBlocks(state)
118// state.data.splice(state.pos1.index + 1, state.pos2.index - state.pos1.index)
119// start.children = lhs + rhs
120// this.collapsePos()
121// },
122// insertText(data, trimL = 0, trimR = 0) {
123// this.resolveSelection()
124// const start = state.data[state.pos1.index]
125// start.children = start.children.slice(0, state.pos1.rel - trimL) + data + start.children.slice(state.pos2.rel + trimR)
126// this.incrementPos(-trimL + data.length)
127// },
128// incrementPos(amount) {
129// state.pos1.abs += amount
130// state.pos1.rel += amount
131// this.collapsePos()
132// },
133// createParagraph() {
134// this.resolveSelection()
135// // Use `let` because start is updated later.
136// let { start, lhs, rhs } = getBlocks(state)
137// start.children = lhs
138// state.data.splice(state.pos1.index + 1, 0, ...parse("")) // E.g. newline.
139// start = state.data[state.pos1.index + 1]
140// start.children = rhs
141// this.incrementPos(1)
142// },
143// deleteParagraph(dir) {
144// // ...
145// },
146// collapsePos() {
147// Object.assign(state.pos2, state.pos1)
148// }
149// }
150// return methods
151// }
152
153function getItems(state) {
154 const item1 = state.data[state.pos1.index]
155 const item2 = state.data[state.pos2.index]
156 const lhs = item1.children.slice(0, state.pos1.offset)
157 const rhs = item2.children.slice(state.pos2.offset)
158 return { item1, item2, lhs, rhs }
159}
160
161function editorMethods(state) {
162 const methods = {
163 // `computeMeta` computes `meta`.
164 computeMeta() {
165 // Compute `pos1`.
166 const pos1 = state.data.reduce((pos1, item, index) => {
167 if (index >= state.pos1.index) {
168 if (index === state.pos1.index) {
169 pos1 += state.pos1.offset
170 }
171 return pos1
172 }
173 pos1 += item.children.length + 1
174 return pos1
175 }, 0)
176 // Compute `pos2`.
177 const pos2 = state.data.reduce((pos2, item, index) => {
178 if (index >= state.pos2.index) {
179 if (index === state.pos2.index) {
180 pos2 += state.pos2.offset
181 }
182 return pos2
183 }
184 pos2 += item.children.length + 1
185 return pos2
186 }, 0)
187 // Compute `data`.
188 const data = state.data.reduce((data, item, index) => {
189 data += item.children + (index + 1 < state.data.length ? "\n" : "")
190 return data
191 }, "")
192 state.meta = { ...state.meta, pos1, pos2, data }
193 },
194 // `setPos` sets the cursor’s positions.
195 setPos(pos1, pos2) {
196 const min = pos1.absolute < pos2.absolute ? pos1 : pos2
197 const max = pos1.absolute < pos2.absolute ? pos2 : pos1
198 Object.assign(state, {
199 ...state,
200 pos1: {
201 ...state.pos1,
202 index: min.index,
203 offset: min.offset
204 },
205 pos2: {
206 ...state.pos2,
207 index: max.index,
208 offset: max.offset
209 }
210 })
211 this.computeMeta()
212 },
213 // `collapse` collapses the cursor to the start
214 // position.
215 collapse() {
216 state.pos2 = { ...state.pos1 }
217 this.computeMeta()
218 },
219 // `insertText` inserts text and resolves the selection
220 // if needed.
221 insertText(data) {
222 const { item1, item2, lhs, rhs } = getItems(state)
223 if (item1.key !== item2.key) {
224 state.data.splice(state.pos1.index + 1, state.pos2.index - state.pos1.index)
225 }
226 item1.children = lhs + data + rhs
227 state.pos1.offset += data.length
228 this.collapse()
229 },
230 createItem() {
231 this.insertText("")
232 // Use `let` because we’ll reassign `items1`.
233 let { item1, lhs, rhs } = getItems(state)
234 item1.children = lhs
235 state.data.splice(state.pos1.index + 1, 0, ...parse(""))
236 item1 = state.data[state.pos1.index + 1]
237 item1.children = rhs
238 state.pos1.offset++
239 this.collapse()
240 },
241 deleteItem(dir) {
242 // E.g. `isDeleteLKey`.
243 if (dir < 0) {
244 state.pos1.index--
245 state.pos1.offset = state.data[state.pos1.index].children.length
246 // E.g. `isDeleteLKey`.
247 } else if (dir > 0) {
248 state.pos2.index++
249 state.pos2.offset = 0
250 }
251 this.insertText("")
252 }
253 }
254 return methods
255}
256
257function useEditor(initialValue) {
258 return useMethods(editorMethods, createEditorState(initialValue))
259}
260
261function NewNote(props) {
262 const [state, methods] = useEditor("Oh, hello, world!\nHow are you?\nHello, darkness...")
263
264 return (
265 <Title title="Editing …">
266 <div className="h:4" />
267 <div className="flex -r -x:center">
268 <div className="m-x:1 w:40">
269 <Editor elementName="article" state={state} methods={methods} />
270 <br />
271 <br />
272 <div>
273 <div className="fs:0.6 ls:0 lh:1.2" style={{ fontFamily: "'Monaco'", whiteSpace: "pre", tabSize: 2 }}>
274 {
275 JSON.stringify({
276 meta: { ...state.meta },
277 pos1: { ...state.pos1 },
278 pos2: { ...state.pos2 },
279 data: { ...state.data }
280 },
281 null,
282 "\t"
283 )}
284 </div>
285 </div>
286 </div>
287 </div>
288 <div className="h:16" />
289 </Title>
290 )
291}
292
293const base = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
294
295// `genURL62ID` generates a new URL62 ID.
296function genURL62ID(length = 11) {
297 let url62 = ""
298 for (let index = 0; index < length; index++) {
299 const random = Math.floor(Math.random() * base.length)
300 url62 += base[random]
301 }
302 return url62
303}
304
305function parse(data) {
306 const items = []
307 // `pos2 <= data.length` for the trailing character.
308 for (let pos2 = 0; pos2 <= data.length; pos2++) {
309 let pos1 = pos2
310 let Component = null
311 switch (true) {
312 default:
313 for (; pos2 < data.length && data.charAt(pos2) !== "\n"; pos2++) {
314 // No op.
315 }
316 Component = P
317 break
318 }
319 const children = data.slice(pos1, pos2)
320 items.push({ key: genURL62ID(4), Component, children })
321 }
322 return items
323}
324
325function createEditorState(data) {
326 const state = {
327 meta: {
328 pos1: 0,
329 pos2: 0,
330 data
331 },
332 pos1: {
333 index: 0,
334 offset: 0
335 },
336 pos2: {
337 index: 0,
338 offset: 0
339 },
340 data: parse(data)
341 }
342 return { ...state, stack: [state], index: 0 }
343}
344
345function Editor(props) {
346 const ref = React.useRef(null)
347
348 // Correct the cursor.
349 const didMount = React.useRef(false)
350 React.useLayoutEffect(
351 React.useCallback(() => {
352 // Don’t set the cursor on mount.
353 if (!didMount.current) {
354 didMount.current = true
355 return
356 }
357 const { pos1, pos2 } = props.state.meta
358 if (pos1 !== pos2) {
359 // Do nothing: don’t intercept the cursor if there’s
360 // no selection.
361 return
362 }
363 const { node: node1, offset: offset1 } = findNode(ref.current, pos1)
364 const { node: node2, offset: offset2 } = findNode(ref.current, pos2)
365 const range = document.createRange()
366 range.setStart(node1, offset1)
367 range.setEnd (node2, offset2)
368 const selection = document.getSelection()
369 selection.removeAllRanges()
370 selection.addRange(range)
371 }, [props.state]),
372 [props])
373
374 const handleSelect = e => {
375 const { anchorNode, anchorOffset, focusNode, focusOffset } = document.getSelection()
376 const pos1 = findPos(ref.current, { node: anchorNode, offset: anchorOffset })
377 const pos2 = findPos(ref.current, { node: focusNode , offset: focusOffset })
378 props.methods.setPos(pos1, pos2)
379 }
380
381 const handleKeyPress = e => {
382 e.preventDefault()
383 props.methods.insertText(e.key)
384 }
385
386 const handleKeyDown = e => {
387 const isReturnKey = e.key === "Enter"
388 const isDeleteLKey = e.key === "Backspace"
389 const isDeleteRKey = e.ctrlKey && e.key === "d"
390 if (!isReturnKey && !isDeleteLKey && !isDeleteRKey) {
391 return
392 }
393 switch (true) {
394 case isReturnKey:
395 e.preventDefault()
396 props.methods.createItem()
397 break
398 case isDeleteLKey || isDeleteRKey:
399 // Guard deleting the insertion point’s node (no
400 // selection).
401 const { pos1, pos2, data } = props.state.meta
402 if (pos1 === pos2 && ((isDeleteLKey && !pos1) || (isDeleteRKey && pos1 === data.length))) {
403 console.log("a")
404 e.preventDefault()
405 break
406 }
407 // Guard deleting text inside of a paragraph.
408 const { item1, item2, lhs, rhs } = getItems(props.state)
409 if (item1.key === item2.key && ((isDeleteLKey && lhs) || (isDeleteRKey && rhs))) {
410 console.log("b")
411 e.preventDefault()
412 break
413 }
414 // Guard deleting a paragraph/s.
415 if (item1.key !== item2.key || ((isDeleteLKey && !lhs) || (isDeleteRKey && !rhs))) {
416 console.log("c")
417 const dir = pos1 === pos2 ? (isDeleteLKey ? -1 : 1) : 0
418 props.methods.deleteItem(dir)
419 e.preventDefault()
420 break
421 }
422 break
423
424 default:
425 break
426 }
427 }
428
429 return (
430 React.createElement(
431 props.elementName || "div",
432 {
433 ref,
434 contentEditable: true,
435 suppressContentEditableWarning: true,
436
437 onSelect: handleSelect,
438 onKeyPress: handleKeyPress,
439 onKeyDown: handleKeyDown,
440 // onInput: handleInput
441 },
442 props.state.data.map(({ Component, key, children }) => (
443 <Component key={key} className="fs:1.2 lh:1.4" children={
444 !children ? <span><br /></span> : children
445 } />
446 ))
447 )
448 )
449}
450
451Editor.propTypes = {
452 elementName: PropTypes.string,
453 state: PropTypes.object.isRequired,
454 methods: PropTypes.object.isRequired
455}
456
457function P(props) {
458 return (
459 <p className="fs:1.2 lh:1.4 debug:text">
460 {props.children}
461 </p>
462 )
463}
464
465P.propTypes = {
466 children: PropTypes.node.isRequired
467}
468
469export { NewNote }