Last active
June 18, 2019 04:11
-
-
Save tim-evans/c3d154da9323750ce3e9629f99bae68c 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 { InlineAnnotation } from '@atjson/document'; | |
/** | |
* We store the user intent on the cursor so we can | |
* correctly position the cursor in a variety of cases | |
* where the browser's DOM APIs give use incorrect | |
* positions. | |
* | |
* Edge cases that we need to handle: | |
* | |
* - At the end of a soft-break on a line of text, | |
* Firefox will give the user the option to use the | |
* keyboard to motion on either "side" of the soft-break | |
* (the beginning or end of the line). We need to store | |
* the user intent (motion) so we can correctly position | |
* the cursor. | |
* - Using a cursor in Chrome and Safari at the end of a line | |
* will result in the cursor jumping to the next line, | |
* but clicking at the end of the line will position the | |
* cursor on the far side of the soft break. We need the | |
* pointer intent to forcibly position the cursor at the | |
* end of the line. | |
* - Using up and down arrows will "fix" the cursor at the | |
* beginning of the line or end of the line if the | |
* text has ragged rows. We need to store the motion and | |
* the "side" of the text that the cursor was on to | |
* determine the correct position of the cursor. | |
* - Beginning and end of line commands are pretty self- | |
* explanatory, but require special handling so the cursor | |
* doesn't end up on a next / previous line. | |
* | |
* We use the cursor affinity here to refer to where the best | |
* place to position the cursor on screen. If the cursor is to | |
* be placed after a soft-wrap opportunity, then the affinity | |
* will be 'backwards'. | |
* | |
* If the cursor is to be positioned at the beginning of a line, | |
* the cursor affinity will be 'forwards'. For most cases, the | |
* cursor affinity will be 'forwards'. | |
*/ | |
export default class Cursor extends InlineAnnotation<{ | |
affinity: 'forwards' | 'backwards'; | |
}> { | |
static vendorPrefix = 'offset'; | |
static type = 'cursor'; | |
} |
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 * as React from 'react'; | |
import { FC, useEffect, useRef, useState } from 'react'; | |
import styled from 'styled-components'; | |
import { Rectangle } from '../../src'; | |
/*const blink = keyframes` | |
0% { opacity: 1; } | |
50% { opacity: 1; } | |
55% { opacity: 0; } | |
100% { opacity: 0; } | |
`;*/ | |
/** | |
* Cross-browser support for caretPositionFromPoint. | |
* This returns a `CaretPosition` like object instead | |
* of a CaretPosition, since we can't create it for | |
* browsers that don't support this API. | |
*/ | |
function caretPositionFromPoint(x: number, y: number): { | |
offsetNode: Node; | |
offset: number; | |
getClientRect(): ClientRect | DOMRect; | |
} | null { | |
// @ts-ignore | |
if (document.caretPositionFromPoint) { | |
// @ts-ignore | |
let position = document.caretPositionFromPoint(x, y); | |
return position ? { | |
offsetNode: position.offsetNode, | |
offset: position.offset, | |
getClientRect() { | |
return position.getClientRect(); | |
} | |
} : null; | |
} else { | |
let range = document.caretRangeFromPoint(x, y); | |
return range ? { | |
offsetNode: range.startContainer, | |
offset: range.startOffset, | |
getClientRect() { | |
return range.getClientRects()[0]; | |
} | |
} : null; | |
} | |
} | |
export type CursorColors = 'blue' | 'red' | 'green' | 'purple' | 'orange' | 'yellow' | 'pink'; | |
function getColor(props: { isCursor: boolean, color: CursorColors }) { | |
switch (props.color) { | |
case 'blue': | |
return props.isCursor ? | |
'rgba(0, 123, 238, 0.95)' : | |
'rgba(41, 116, 255, 0.25)'; | |
case 'red': | |
return props.isCursor ? | |
'rgba(255,69,118,0.95)' : | |
'rgba(255,69,87,0.25)'; | |
case 'green': | |
return props.isCursor ? | |
'rgba(24, 223, 1, 0.95)' : | |
'rgba(26, 255, 26, 0.25)'; | |
case 'purple': | |
return props.isCursor ? | |
'rgba(99, 63, 255, 0.95)' : | |
'rgba(147, 87, 255, 0.25)'; | |
case 'orange': | |
return props.isCursor ? | |
'rgba(255,84,0,0.95)' : | |
'rgba(255,127,0,0.25)'; | |
case 'yellow': | |
return props.isCursor ? | |
'rgba(247, 165, 0, 0.95)' : | |
'rgba(255, 191, 71, 0.25)'; | |
case 'pink': | |
return props.isCursor ? | |
'rgba(255, 0, 215, 0.95)' : | |
'rgba(255, 82, 252, 0.25)'; | |
} | |
} | |
const Svg = styled.svg<{ isCursor: boolean, color: CursorColors }>` | |
position: fixed; | |
top: 0; | |
left: 0; | |
width: 100vw; | |
height: 100vh; | |
fill: ${props => getColor(props)}; | |
pointer-events: none; | |
mix-blend-mode: multiply; | |
circle { | |
fill: ${props => getColor({ isCursor: true, color: props.color })}; | |
} | |
rect { | |
fill: transparent; | |
stroke-width: 1px; | |
stroke: ${props => getColor({ isCursor: true, color: props.color })}; | |
} | |
text { | |
font-size: 10px; | |
fill: ${props => getColor({ isCursor: true, color: props.color })}; | |
} | |
`; | |
const Wrapper = styled.div` | |
* { | |
color: transparent; | |
-webkit-text-fill-color: black; | |
} | |
*::selection { | |
background: transparent; | |
} | |
*:focus { | |
outline: none; | |
} | |
`; | |
export const Highlighter: FC<{ | |
debug?: boolean; | |
color?: CursorColors; | |
}> = props => { | |
let [rectangles, setRectangles] = useState<Rectangle[]>([]); | |
let [scrollY, setScrollY] = useState(0); | |
let [isCollapsed, setCollapsed] = useState(false); | |
let ref = useRef<HTMLDivElement>(null); | |
useEffect(() => { | |
let change = () => { | |
let selection = document.getSelection(); | |
setCollapsed(selection.isCollapsed); | |
let maxWidth = Infinity; | |
if (ref.current) { | |
let contents = ref.current.children; | |
if (contents.length > 0) { | |
let rect = contents[0].getBoundingClientRect(); | |
maxWidth = rect.right; | |
} | |
} | |
if (selection.isCollapsed && selection.rangeCount > 0) { | |
let nextCharRange = document.createRange(); | |
let range = selection.getRangeAt(0); | |
try { | |
nextCharRange.setStart(range.startContainer, range.startOffset); | |
nextCharRange.setEnd(range.endContainer, range.endOffset + 1); | |
let clientRects = nextCharRange.getClientRects(); | |
let nextCursorRect = Rectangle.fromDOMRect(clientRects[clientRects.length - 1]); | |
let caretPosition = caretPositionFromPoint(nextCursorRect.left, nextCursorRect.top); | |
// Firefox has different text behaviour where it will | |
// cause 2 cursor movements when at a soft-break opportunity | |
// at the end of a line. We should handle this by showing the | |
// cursor at the space location at the end of the line instead | |
// of forcing the cursor to the next line. | |
if (clientRects.length === 1) { | |
if (caretPosition) { | |
setRectangles([Rectangle.fromDOMRect(caretPosition.getClientRect()).translateY(window.scrollY)]); | |
} | |
// Chrome and Safari create two client rects when there's a soft-break | |
// opportunity, so we'll handle their cases here | |
} else { | |
setRectangles([nextCursorRect.translateY(window.scrollY)]); | |
} | |
} catch (e) { | |
if (range.getClientRects()[0]) { | |
setRectangles([Rectangle.fromDOMRect(range.getClientRects()[0]).translateY(window.scrollY)]); | |
} | |
} | |
} else if (selection) { | |
let rects: Rectangle[] = []; | |
for (let rangeIndex = 0, rangeCount = selection.rangeCount; rangeIndex < rangeCount; rangeIndex++) { | |
let range = selection.getRangeAt(rangeIndex); | |
let clientRects = range.getClientRects(); | |
for (let i = 0, len = clientRects.length; i < len; i++) { | |
let clientRect = clientRects[i]; | |
let nextRect = clientRects[i + 1]; | |
let rect = Rectangle.fromDOMRect(clientRect).translateY(window.scrollY); | |
if (nextRect == null) { | |
rects.push(rect); | |
continue; | |
} | |
// Safari and Chrome return a rectangle for soft-break | |
// opportunities. We're going to use these to get the full-width of | |
// the line. | |
if (Math.round(nextRect.left - clientRect.right) === 0 && | |
nextRect.top === clientRect.top) { | |
rect.width += nextRect.width; | |
i++; // Skip the next rectangle, since it's a soft-break opportunity | |
// For Firefox, we have to backtrack to find where the cursor | |
// _would_ be in the case that there was a cursor after the soft-break | |
// opportunity at the end of a line. | |
} else { | |
let caretPosition = caretPositionFromPoint(nextRect.left, nextRect.top); | |
if (caretPosition) { | |
let adjustedRect = caretPosition.getClientRect(); | |
if (adjustedRect) { | |
let cursor = Rectangle.fromDOMRect(adjustedRect).translateY(window.scrollY); | |
if (Math.round(cursor.top - rect.top) === 0) { | |
rect.width += cursor.right - rect.right; | |
} | |
} | |
} | |
} | |
// The right-hand side of the highlighted lines | |
// should be clamped to the width of the contenteditable | |
// container. This is a weird OS thing that happens for | |
// text highlights, but *not* cursors- soft break opportunities | |
// on Firefox will extend beyond the actual contenteditable | |
// contents, so we only handle this edge case here | |
if (rect.right > maxWidth) { | |
rect.width -= rect.right - maxWidth; | |
} | |
rects.push(rect); | |
} | |
} | |
setRectangles(rects); | |
} else { | |
setRectangles([]); | |
} | |
}; | |
change(); | |
window.addEventListener('resize', change); | |
document.addEventListener('selectionchange', change); | |
return () => { | |
window.removeEventListener('resize', change); | |
document.removeEventListener('selectionchange', change); | |
}; | |
}, []); | |
useEffect(() => { | |
let change = () => setScrollY(window.scrollY); | |
change(); | |
window.addEventListener('scroll', change, { passive: false }); | |
return () => { | |
window.removeEventListener('scroll', change); | |
}; | |
}, []); | |
let polygon: Array<[number, number]> = []; | |
if (!isCollapsed) { | |
/** | |
* We need to carve the selection because there may be content | |
* that breaks the flow of the text selection. | |
* | |
* We don't want ragged edges because of different line lengths, | |
* but we do want to carve out the left and right side margins | |
* to account for asides. | |
*/ | |
let left: number | null = null; | |
let right: number | null = null; | |
let top: number | null = null; | |
for (let i = 0, len = rectangles.length; i < len; i++) { | |
let rect = rectangles[i]; | |
if (left == null || right == null || top == null) { | |
polygon.push( | |
[rect.left, rect.top], | |
[rect.right, rect.top] | |
); | |
left = rect.left; | |
right = rect.right; | |
top = rect.bottom; | |
} else if (rect.left !== left) { | |
polygon.unshift( | |
[rect.left, top], | |
[left, top] | |
); | |
left = rect.left; | |
} | |
if (rect.right !== right) { | |
polygon.push( | |
[right, top], | |
[rect.right, top] | |
); | |
right = rect.right; | |
} | |
if (i === len - 1) { | |
let lastPoint = polygon[polygon.length - 1]; | |
if (lastPoint && (lastPoint[0] !== right || | |
lastPoint[1] !== rect.top)) { | |
polygon.push([right, rect.top]); | |
} | |
if (rect.right !== right) { | |
polygon.push([rect.right, rect.top]); | |
} | |
polygon.push( | |
[rect.right, rect.bottom], | |
[rect.left, rect.bottom] | |
); | |
} | |
if (top !== rect.bottom) { | |
top = rect.bottom; | |
} | |
} | |
} else if (rectangles.length) { | |
let cursor = rectangles[0]; | |
polygon.push( | |
[cursor.left - 1.5, cursor.top], | |
[cursor.left + 0.5, cursor.top], | |
[cursor.left + 0.5, cursor.bottom], | |
[cursor.left - 1.5, cursor.bottom] | |
); | |
} | |
return ( | |
<> | |
<Svg isCursor={isCollapsed} color={props.color || 'blue'}> | |
<g transform={`translate(0 -${scrollY})`}> | |
<polygon points={polygon.map(([x, y]) => `${x},${y}`).join(' ')}/> | |
{props.debug && !isCollapsed && | |
polygon.map(([x, y], i) => ( | |
<text key={i} x={x} y={y}>{' '}{i + 1}</text> | |
)) | |
} | |
{props.debug && !isCollapsed && | |
polygon.map(([x, y], i) => ( | |
<circle key={i} cx={x} cy={y} r='2' /> | |
)) | |
} | |
{props.debug && !isCollapsed && | |
rectangles.map((rect, i) => ( | |
<rect key={i} x={rect.x} y={rect.y} width={rect.width} height={rect.height} /> | |
)) | |
} | |
</g> | |
</Svg> | |
<Wrapper ref={ref}>{props.children}</Wrapper> | |
</> | |
); | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment