Skip to content

Instantly share code, notes, and snippets.

@tim-evans
Last active June 18, 2019 04:11
Show Gist options
  • Save tim-evans/c3d154da9323750ce3e9629f99bae68c to your computer and use it in GitHub Desktop.
Save tim-evans/c3d154da9323750ce3e9629f99bae68c to your computer and use it in GitHub Desktop.
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';
}
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