Files
omnivore/packages/web/lib/highlights/useSelection.tsx
Jackson Harper 6daa599b76 Separate the mutations out of lower level components
This will let us handle the mutations differently on native
iOS and should help in avoiding making web calls directly from
the web view, so we can avoid CORs and introduce a caching
layer.
2022-03-22 13:58:39 -07:00

216 lines
6.4 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react'
import {
wrapHighlightTagAroundRange,
getHighlightElements,
HighlightLocation,
} from './highlightGenerator'
import type { SelectionAttributes } from './highlightHelpers'
export function useSelection(
highlightLocations: HighlightLocation[],
isDisabled: boolean
): [SelectionAttributes | null, (x: SelectionAttributes | null) => void] {
const disabled = isDisabled
const [selectionAttributes, setSelectionAttributes] =
useState<SelectionAttributes | null>(null)
const handleFinishTouch = useCallback(
async (mouseEvent) => {
const result = await makeSelectionRange()
if (!result) {
return setTimeout(() => {
setSelectionAttributes(null)
}, 100)
}
const { range, isReverseSelected, selection } = result
const [selectionStart, selectionEnd] = wrapHighlightTagAroundRange(range)
const rangeRect = rangeToPos(range, isReverseSelected)
let shouldCancelSelection = false
const overlapHighlights: HighlightLocation[] = []
highlightLocations
.sort((a, b) => {
if (a.start < b.start) {
return -1
}
if (a.start > b.start) {
return 1
}
return 0
})
.forEach((highlightLocation) => {
// Cancel operations if highlight is subset of existing highlight
if (
selectionStart >= highlightLocation.start &&
selectionEnd <= highlightLocation.end
) {
shouldCancelSelection = true
return
}
if (
selectionStart <= highlightLocation.end &&
highlightLocation.start <= selectionEnd
) {
overlapHighlights.push(highlightLocation)
}
})
/* If selection overlaps existing highlights, compute and return the merged selection range */
let mergedRange = null
if (overlapHighlights.length) {
let startNode, startOffset
let endNode, endOffset
let extendEndNode = false
if (selectionStart <= overlapHighlights[0].start) {
startNode = range.startContainer
startOffset = range.startOffset
} else {
const highlightElems = getHighlightElements(overlapHighlights[0].id)
startNode = highlightElems.shift()
startOffset = 0
}
if (
selectionEnd >= overlapHighlights[overlapHighlights.length - 1].end
) {
endNode = range.endContainer
endOffset = range.endOffset
} else {
const highlightElems = getHighlightElements(
overlapHighlights[overlapHighlights.length - 1].id
)
endNode = highlightElems.pop()
endOffset = 0
/* end node is from highlight span, we must extend range until the end of highlight span */
extendEndNode = true
}
if (!startNode || !endNode) {
throw new Error('Failed to query node for computing new merged range')
}
mergedRange = new Range()
mergedRange.setStart(startNode, startOffset)
if (extendEndNode) {
/* Extend the range to include the entire endNode container */
mergedRange.setEndAfter(endNode)
} else {
mergedRange.setEnd(endNode, endOffset)
}
}
if (shouldCancelSelection) {
return setTimeout(() => {
setSelectionAttributes(null)
}, 100)
}
return setSelectionAttributes({
selection,
mouseEvent,
range: mergedRange ?? range,
focusPosition: {
x: rangeRect[isReverseSelected ? 'left' : 'right'],
y: rangeRect[isReverseSelected ? 'top' : 'bottom'],
isReverseSelected,
},
overlapHighlights: overlapHighlights.map(({ id }) => id),
})
},
[highlightLocations]
)
useEffect(() => {
if (disabled) {
return
}
document.addEventListener('mouseup', handleFinishTouch)
document.addEventListener('touchend', handleFinishTouch)
document.addEventListener('contextmenu', handleFinishTouch)
return () => {
document.removeEventListener('mouseup', handleFinishTouch)
document.removeEventListener('touchend', handleFinishTouch)
document.removeEventListener('contextmenu', handleFinishTouch)
}
}, [highlightLocations, handleFinishTouch, disabled])
return [selectionAttributes, setSelectionAttributes]
}
type MakeSelectionRangeOutput = {
range: Range
selection: Selection
isReverseSelected: boolean
}
async function makeSelectionRange(): Promise<
MakeSelectionRangeOutput | undefined
> {
// Getting the selection a little bit after the mouseup event b/c the mouseup event is triggered earlier
// than the selection is getting cleared
const selection = await new Promise<Selection | null>((resolve) => {
setTimeout(() => {
resolve(document.getSelection())
}, 100)
})
if (!selection || selection.isCollapsed || selection.rangeCount <= 0) {
return undefined
}
const articleContentElement = document.getElementById('article-container')
if (!articleContentElement)
throw new Error('Unable to find the article content element')
const allowedRange = new Range()
allowedRange.selectNode(articleContentElement)
const range = selection.getRangeAt(0)
const start = range.compareBoundaryPoints(Range.START_TO_START, allowedRange)
const end = range.compareBoundaryPoints(Range.END_TO_END, allowedRange)
const isRangeAllowed = start >= 0 && end <= 0
const isReverseSelected =
range.startContainer === selection.focusNode &&
range.endOffset === selection.anchorOffset
return isRangeAllowed ? { range, isReverseSelected, selection } : undefined
}
export type RangeEndPos = {
left: number
top: number
right: number
bottom: number
width: number
height: number
}
const rangeToPos = (range: Range, getFirst = false): RangeEndPos => {
if (typeof window === 'undefined' || !range) {
return { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 }
}
const rects = range.getClientRects()
if (!rects || !rects.length) {
return { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 }
}
const rect = rects[getFirst ? 0 : rects.length - 1]
return {
left: window.scrollX + rect.left,
top: window.scrollY + rect.top,
right: window.scrollX + rect.right,
bottom: window.scrollY + rect.bottom,
width: rect.width,
height: rect.height,
}
}