Skip to content

Instantly share code, notes, and snippets.

@zaydek-old
Last active August 22, 2019 18:10
Show Gist options
  • Save zaydek-old/6d2f649020514670cde3a13a0a9e8910 to your computer and use it in GitHub Desktop.
Save zaydek-old/6d2f649020514670cde3a13a0a9e8910 to your computer and use it in GitHub Desktop.
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