Last active
August 22, 2019 18:10
-
-
Save zaydek-old/6d2f649020514670cde3a13a0a9e8910 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 React from "react" | |
import PropTypes from "prop-types" | |
import useMethods from "use-methods" | |
import { Title } from "./Title" | |
// `isBreakNode` checks whether a node is `<br />` OR | |
// `<span><br /></span>`. | |
function isBreakNode(node) { | |
const isBreak = ( | |
node.nodeType === Node.ELEMENT_NODE && ( | |
node.nodeName === "BR" || ( | |
node.nodeName === "SPAN" && | |
node.childNodes.length === 1 && | |
node.childNodes[0].nodeType === Node.ELEMENT_NODE && | |
node.childNodes[0].nodeName === "BR" | |
) | |
) | |
) | |
return isBreak | |
} | |
function isTextNode(node) { | |
return node.nodeType === Node.TEXT_NODE | |
} | |
function readNode(node) { | |
return isBreakNode(node) ? "" : node.nodeValue | |
} | |
// `findNode` finds the node and its offset from an absolute | |
// position. | |
function findNode(root, pos) { | |
const node = { | |
node: null, | |
offset: 0 | |
} | |
;(function recurse(startNode) { | |
for (const child of startNode.childNodes) { | |
if (isBreakNode(child) || isTextNode(child)) { | |
const length = readNode(child).length | |
if (pos - length <= 0) { | |
Object.assign(node, { node: child, offset: pos }) | |
return true | |
} | |
pos -= length | |
} else { | |
if (recurse(child)) { | |
return true | |
} | |
if (child.parentNode === root && child.nextSibling) { | |
pos-- | |
} | |
} | |
} | |
return false | |
})(root) | |
return node | |
} | |
// `findPos` finds the absolute position, relative position, | |
// and block index of a node and its offset. | |
function findPos(root, { node, offset }) { | |
const pos = { | |
absolute: 0, | |
index: 0, | |
offset: 0 | |
} | |
// Guard `<br />`: the selection API sometimes selects the | |
// parent node instead of `<br />`. | |
if (node.nodeType === Node.ELEMENT_NODE && !isBreakNode(node)) { | |
node = node.childNodes[offset] | |
offset = 0 | |
} | |
;(function recurse(startNode) { | |
for (const child of startNode.childNodes) { | |
if (isBreakNode(child) || isTextNode(child)) { | |
if (child === node) { | |
pos.absolute += offset | |
pos.offset = offset | |
return true | |
} | |
pos.absolute += readNode(child).length | |
} else { | |
if (recurse(child)) { | |
return true | |
} | |
if (child.parentNode === root && child.nextSibling) { | |
pos.absolute ++ | |
pos.index++ | |
} | |
} | |
} | |
return false | |
})(root) | |
return pos | |
} | |
// function editorMethods(state) { | |
// const methods = { | |
// // updatePos updates `pos1` and `pos2`, generally from | |
// // the result of `findPos`. | |
// updatePos(pos1, pos2) { | |
// const min = pos1.abs < pos2.abs ? pos1 : pos2 | |
// const max = pos1.abs < pos2.abs ? pos2 : pos1 | |
// Object.assign(state.pos1, min) | |
// Object.assign(state.pos2, max) | |
// }, | |
// // `resolveSelection` resolves the selection’s start and | |
// // end blocks, merging them and dropping intermediary | |
// // blocks if necessary. | |
// resolveSelection() { | |
// if (state.pos1.index === state.pos2.index) { | |
// return | |
// } | |
// const { start, lhs, rhs } = getBlocks(state) | |
// state.data.splice(state.pos1.index + 1, state.pos2.index - state.pos1.index) | |
// start.children = lhs + rhs | |
// this.collapsePos() | |
// }, | |
// insertText(data, trimL = 0, trimR = 0) { | |
// this.resolveSelection() | |
// const start = state.data[state.pos1.index] | |
// start.children = start.children.slice(0, state.pos1.rel - trimL) + data + start.children.slice(state.pos2.rel + trimR) | |
// this.incrementPos(-trimL + data.length) | |
// }, | |
// incrementPos(amount) { | |
// state.pos1.abs += amount | |
// state.pos1.rel += amount | |
// this.collapsePos() | |
// }, | |
// createParagraph() { | |
// this.resolveSelection() | |
// // Use `let` because start is updated later. | |
// let { start, lhs, rhs } = getBlocks(state) | |
// start.children = lhs | |
// state.data.splice(state.pos1.index + 1, 0, ...parse("")) // E.g. newline. | |
// start = state.data[state.pos1.index + 1] | |
// start.children = rhs | |
// this.incrementPos(1) | |
// }, | |
// deleteParagraph(dir) { | |
// // ... | |
// }, | |
// collapsePos() { | |
// Object.assign(state.pos2, state.pos1) | |
// } | |
// } | |
// return methods | |
// } | |
function getItems(state) { | |
const item1 = state.data[state.pos1.index] | |
const item2 = state.data[state.pos2.index] | |
const lhs = item1.children.slice(0, state.pos1.offset) | |
const rhs = item2.children.slice(state.pos2.offset) | |
return { item1, item2, lhs, rhs } | |
} | |
function editorMethods(state) { | |
const methods = { | |
// `computeMeta` computes `meta`. | |
computeMeta() { | |
// Compute `pos1`. | |
const pos1 = state.data.reduce((pos1, item, index) => { | |
if (index >= state.pos1.index) { | |
if (index === state.pos1.index) { | |
pos1 += state.pos1.offset | |
} | |
return pos1 | |
} | |
pos1 += item.children.length + 1 | |
return pos1 | |
}, 0) | |
// Compute `pos2`. | |
const pos2 = state.data.reduce((pos2, item, index) => { | |
if (index >= state.pos2.index) { | |
if (index === state.pos2.index) { | |
pos2 += state.pos2.offset | |
} | |
return pos2 | |
} | |
pos2 += item.children.length + 1 | |
return pos2 | |
}, 0) | |
// Compute `data`. | |
const data = state.data.reduce((data, item, index) => { | |
data += item.children + (index + 1 < state.data.length ? "\n" : "") | |
return data | |
}, "") | |
state.meta = { ...state.meta, pos1, pos2, data } | |
}, | |
// `setPos` sets the cursor’s positions. | |
setPos(pos1, pos2) { | |
const min = pos1.absolute < pos2.absolute ? pos1 : pos2 | |
const max = pos1.absolute < pos2.absolute ? pos2 : pos1 | |
Object.assign(state, { | |
...state, | |
pos1: { | |
...state.pos1, | |
index: min.index, | |
offset: min.offset | |
}, | |
pos2: { | |
...state.pos2, | |
index: max.index, | |
offset: max.offset | |
} | |
}) | |
this.computeMeta() | |
}, | |
// `collapse` collapses the cursor to the start | |
// position. | |
collapse() { | |
state.pos2 = { ...state.pos1 } | |
this.computeMeta() | |
}, | |
// `insertText` inserts text and resolves the selection | |
// if needed. | |
insertText(data) { | |
const { item1, item2, lhs, rhs } = getItems(state) | |
if (item1.key !== item2.key) { | |
state.data.splice(state.pos1.index + 1, state.pos2.index - state.pos1.index) | |
} | |
item1.children = lhs + data + rhs | |
state.pos1.offset += data.length | |
this.collapse() | |
}, | |
createItem() { | |
this.insertText("") | |
// Use `let` because we’ll reassign `items1`. | |
let { item1, lhs, rhs } = getItems(state) | |
item1.children = lhs | |
state.data.splice(state.pos1.index + 1, 0, ...parse("")) | |
item1 = state.data[state.pos1.index + 1] | |
item1.children = rhs | |
state.pos1.offset++ | |
this.collapse() | |
}, | |
deleteItem(dir) { | |
// E.g. `isDeleteLKey`. | |
if (dir < 0) { | |
state.pos1.index-- | |
state.pos1.offset = state.data[state.pos1.index].children.length | |
// E.g. `isDeleteLKey`. | |
} else if (dir > 0) { | |
state.pos2.index++ | |
state.pos2.offset = 0 | |
} | |
this.insertText("") | |
} | |
} | |
return methods | |
} | |
function useEditor(initialValue) { | |
return useMethods(editorMethods, createEditorState(initialValue)) | |
} | |
function NewNote(props) { | |
const [state, methods] = useEditor("Oh, hello, world!\nHow are you?\nHello, darkness...") | |
return ( | |
<Title title="Editing …"> | |
<div className="h:4" /> | |
<div className="flex -r -x:center"> | |
<div className="m-x:1 w:40"> | |
<Editor elementName="article" state={state} methods={methods} /> | |
<br /> | |
<br /> | |
<div> | |
<div className="fs:0.6 ls:0 lh:1.2" style={{ fontFamily: "'Monaco'", whiteSpace: "pre", tabSize: 2 }}> | |
{ | |
JSON.stringify({ | |
meta: { ...state.meta }, | |
pos1: { ...state.pos1 }, | |
pos2: { ...state.pos2 }, | |
data: { ...state.data } | |
}, | |
null, | |
"\t" | |
)} | |
</div> | |
</div> | |
</div> | |
</div> | |
<div className="h:16" /> | |
</Title> | |
) | |
} | |
const base = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" | |
// `genURL62ID` generates a new URL62 ID. | |
function genURL62ID(length = 11) { | |
let url62 = "" | |
for (let index = 0; index < length; index++) { | |
const random = Math.floor(Math.random() * base.length) | |
url62 += base[random] | |
} | |
return url62 | |
} | |
function parse(data) { | |
const items = [] | |
// `pos2 <= data.length` for the trailing character. | |
for (let pos2 = 0; pos2 <= data.length; pos2++) { | |
let pos1 = pos2 | |
let Component = null | |
switch (true) { | |
default: | |
for (; pos2 < data.length && data.charAt(pos2) !== "\n"; pos2++) { | |
// No op. | |
} | |
Component = P | |
break | |
} | |
const children = data.slice(pos1, pos2) | |
items.push({ key: genURL62ID(4), Component, children }) | |
} | |
return items | |
} | |
function createEditorState(data) { | |
const state = { | |
meta: { | |
pos1: 0, | |
pos2: 0, | |
data | |
}, | |
pos1: { | |
index: 0, | |
offset: 0 | |
}, | |
pos2: { | |
index: 0, | |
offset: 0 | |
}, | |
data: parse(data) | |
} | |
return { ...state, stack: [state], index: 0 } | |
} | |
function Editor(props) { | |
const ref = React.useRef(null) | |
// Correct the cursor. | |
const didMount = React.useRef(false) | |
React.useLayoutEffect( | |
React.useCallback(() => { | |
// Don’t set the cursor on mount. | |
if (!didMount.current) { | |
didMount.current = true | |
return | |
} | |
const { pos1, pos2 } = props.state.meta | |
if (pos1 !== pos2) { | |
// Do nothing: don’t intercept the cursor if there’s | |
// no selection. | |
return | |
} | |
const { node: node1, offset: offset1 } = findNode(ref.current, pos1) | |
const { node: node2, offset: offset2 } = findNode(ref.current, pos2) | |
const range = document.createRange() | |
range.setStart(node1, offset1) | |
range.setEnd (node2, offset2) | |
const selection = document.getSelection() | |
selection.removeAllRanges() | |
selection.addRange(range) | |
}, [props.state]), | |
[props]) | |
const handleSelect = e => { | |
const { anchorNode, anchorOffset, focusNode, focusOffset } = document.getSelection() | |
const pos1 = findPos(ref.current, { node: anchorNode, offset: anchorOffset }) | |
const pos2 = findPos(ref.current, { node: focusNode , offset: focusOffset }) | |
props.methods.setPos(pos1, pos2) | |
} | |
const handleKeyPress = e => { | |
e.preventDefault() | |
props.methods.insertText(e.key) | |
} | |
const handleKeyDown = e => { | |
const isReturnKey = e.key === "Enter" | |
const isDeleteLKey = e.key === "Backspace" | |
const isDeleteRKey = e.ctrlKey && e.key === "d" | |
if (!isReturnKey && !isDeleteLKey && !isDeleteRKey) { | |
return | |
} | |
switch (true) { | |
case isReturnKey: | |
e.preventDefault() | |
props.methods.createItem() | |
break | |
case isDeleteLKey || isDeleteRKey: | |
// Guard deleting the insertion point’s node (no | |
// selection). | |
const { pos1, pos2, data } = props.state.meta | |
if (pos1 === pos2 && ((isDeleteLKey && !pos1) || (isDeleteRKey && pos1 === data.length))) { | |
console.log("a") | |
e.preventDefault() | |
break | |
} | |
// Guard deleting text inside of a paragraph. | |
const { item1, item2, lhs, rhs } = getItems(props.state) | |
if (item1.key === item2.key && ((isDeleteLKey && lhs) || (isDeleteRKey && rhs))) { | |
console.log("b") | |
e.preventDefault() | |
break | |
} | |
// Guard deleting a paragraph/s. | |
if (item1.key !== item2.key || ((isDeleteLKey && !lhs) || (isDeleteRKey && !rhs))) { | |
console.log("c") | |
const dir = pos1 === pos2 ? (isDeleteLKey ? -1 : 1) : 0 | |
props.methods.deleteItem(dir) | |
e.preventDefault() | |
break | |
} | |
break | |
default: | |
break | |
} | |
} | |
return ( | |
React.createElement( | |
props.elementName || "div", | |
{ | |
ref, | |
contentEditable: true, | |
suppressContentEditableWarning: true, | |
onSelect: handleSelect, | |
onKeyPress: handleKeyPress, | |
onKeyDown: handleKeyDown, | |
// onInput: handleInput | |
}, | |
props.state.data.map(({ Component, key, children }) => ( | |
<Component key={key} className="fs:1.2 lh:1.4" children={ | |
!children ? <span><br /></span> : children | |
} /> | |
)) | |
) | |
) | |
} | |
Editor.propTypes = { | |
elementName: PropTypes.string, | |
state: PropTypes.object.isRequired, | |
methods: PropTypes.object.isRequired | |
} | |
function P(props) { | |
return ( | |
<p className="fs:1.2 lh:1.4 debug:text"> | |
{props.children} | |
</p> | |
) | |
} | |
P.propTypes = { | |
children: PropTypes.node.isRequired | |
} | |
export { NewNote } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment