Add documentation for highlight and fix bug

Issue: https://github.com/omnivore-app/omnivore/issues/2996
This commit is contained in:
andrew
2023-12-27 19:36:08 -05:00
parent 29d3935d67
commit b23ddcb6bc
2 changed files with 137 additions and 5 deletions

View File

@ -24,6 +24,13 @@ const maxDeepPatchDistance = 4000
const maxDeepPatchThreshhold = 0.5
const maxSurroundingTextLength = 2000
/**
* Wrapper for text node
*
* @property startIndex - offset from the start of article for which the text begins
* @property node - the text node
* @property startsParagraph - whether a new paragraph is started
*/
type TextNode = {
startIndex: number
node: Node
@ -35,12 +42,18 @@ type ArticleTextContent = {
articleText: string
}
/**
* Location of a highlight as starting/ending offset from the start of article. The end offset is non-inclusive
*/
export type HighlightLocation = {
id: string
start: number
end: number
}
/**
* Relevant attributes of a highlight node created in DOM
*/
export type HighlightNodeAttributes = {
prefix: string
suffix: string
@ -73,6 +86,16 @@ function nodeAttributesFromHighlight(
return makeHighlightNodeAttributes(patch, id, withNote, customColor, tooltip)
}
/**
* Make a highlight on the highlight selection and return its attributes
*
* @param patch - {@link generateDiffPatch|patch} of the highlight location
* @param id - highlight id
* @param withNote - whether highlight has notes
* @param customColor - color of highlight
* @param tooltip
* @returns relevant highlight attributes
*/
export function makeHighlightNodeAttributes(
patch: string,
id: string,
@ -184,6 +207,15 @@ export function makeHighlightNodeAttributes(
}
}
/**
* Given a text selection by user, annotate the article around the selection and
* produce a {@link https://github.com/google/diff-match-patch | diff patch}
*
* The diff patch is used for identifying the selection/highlight location
*
* @param range text selection range
* @returns diff patch
*/
export function generateDiffPatch(range: Range): string {
const articleContentElement = document.getElementById(articleContainerId)
if (!articleContentElement)
@ -217,7 +249,12 @@ export function generateDiffPatch(range: Range): string {
return patch
}
export function wrapHighlightTagAroundRange(range: Range): [number, number] {
/**
* Retrieve starting and ending offsets to the highlight selection
* @param range highlight selection
* @returns starting offset and ending offset (non-inclusive)
*/
export function retrieveOffsetsForSelection(range: Range): [number, number] {
const patch = generateDiffPatch(range)
const { highlightTextStart, highlightTextEnd } =
selectionOffsetsFromPatch(patch)
@ -265,6 +302,15 @@ const getArticleTextNodes = (
return { textNodes, articleText }
}
/**
* Return the offsets to the selection/highlight
*
* @param patch {@link generateDiffPatch|diff patch} identifying a selection/highlight location
* @returns
* - highlightTextStart - The start of highlight, offset from the start of article by characters
* - highlightTextEnd - The end of highlight (non-inclusive), offset from the start of article by characters
* - matchingHighlightContent - the matched highlight
*/
const selectionOffsetsFromPatch = (
patch: string
): {

View File

@ -1,11 +1,21 @@
import { useCallback, useEffect, useState } from 'react'
import {
wrapHighlightTagAroundRange,
retrieveOffsetsForSelection,
getHighlightElements,
HighlightLocation,
} from './highlightGenerator'
import type { SelectionAttributes } from './highlightHelpers'
/**
* Get the range of text with {@link SelectionAttributes} that user has selected
*
* Event Handlers for detecting/using selection are registered
*
* If the selection overlaps with existing highlights
*
* @param highlightLocations existing highlights
* @returns selection range and its setter
*/
export function useSelection(
highlightLocations: HighlightLocation[]
): [SelectionAttributes | null, (x: SelectionAttributes | null) => void] {
@ -55,7 +65,7 @@ export function useSelection(
}
const { range, isReverseSelected, selection } = result
const [selectionStart, selectionEnd] = wrapHighlightTagAroundRange(range)
const [selectionStart, selectionEnd] = retrieveOffsetsForSelection(range)
const rangeRect = rangeToPos(range, isReverseSelected)
let shouldCancelSelection = false
@ -82,8 +92,8 @@ export function useSelection(
}
if (
selectionStart <= highlightLocation.end &&
highlightLocation.start <= selectionEnd
selectionStart < highlightLocation.end &&
highlightLocation.start < selectionEnd
) {
overlapHighlights.push(highlightLocation)
}
@ -234,9 +244,78 @@ async function makeSelectionRange(): Promise<
range.startContainer === selection.focusNode &&
range.endOffset === selection.anchorOffset
/**
* Edge case:
* If the selection ends on range endContainer (or startContainer in reverse select) but no text is selected (i.e. selection ends at
* an empty area), the preceding text is highlighted due to range normalizing.
* This is a visual bug and would sometimes lead to weird highlight behavior during removal.
* See X for more detail
*/
const selectionEndNode = selection.focusNode
const selectionEndOffset = selection.focusOffset
const selectionStartNode = isReverseSelected ? range.endContainer : range.startContainer
if (selectionEndNode?.nodeType === Node.TEXT_NODE) {
const selectionEndNodeEdgeIndex = isReverseSelected ? selectionEndNode.textContent?.length : 0
if (selectionStartNode !== selectionEndNode &&
selectionEndOffset == selectionEndNodeEdgeIndex) {
clipRangeToNearestAnchor(range, selectionEndNode, isReverseSelected)
}
}
return isRangeAllowed ? { range, isReverseSelected, selection } : undefined
}
/**
* Clip selection range to the beginning/end of the adjacent anchor element
*
* @param range selection range
* @param selectionEndNode the node where the selection ended at
* @param isReverseSelected
*/
const clipRangeToNearestAnchor = (
range: Range,
selectionEndNode: Node,
isReverseSelected: boolean
) => {
let nearestAnchorElement = selectionEndNode.parentElement
while (nearestAnchorElement !== null && !nearestAnchorElement.hasAttribute('data-omnivore-anchor-idx')) {
nearestAnchorElement = nearestAnchorElement.parentElement;
}
if (!nearestAnchorElement) {
throw Error('Unable to find nearest anchor element for node: ' + selectionEndNode)
}
let anchorId = Number(nearestAnchorElement.getAttribute('data-omnivore-anchor-idx')!)
let adjacentAnchorId, adjacentAnchor, adjacentAnchorOffset
if (isReverseSelected) {
// move down to find adjacent anchor node and clip at its beginning
adjacentAnchorId = anchorId + 1
adjacentAnchor = document.querySelectorAll(`[data-omnivore-anchor-idx='${adjacentAnchorId}']`)[0]
adjacentAnchorOffset = 0
range.setStart(adjacentAnchor, adjacentAnchorOffset)
} else {
// move up to find adjacent anchor node and clip at its end
do {
adjacentAnchorId = --anchorId
adjacentAnchor = document.querySelectorAll(`[data-omnivore-anchor-idx='${adjacentAnchorId}']`)[0]
} while (adjacentAnchor.contains(selectionEndNode))
if (adjacentAnchor.textContent) {
let lastTextNodeChild = adjacentAnchor.lastChild
while (!!lastTextNodeChild && lastTextNodeChild.nodeType !== Node.TEXT_NODE) {
lastTextNodeChild = lastTextNodeChild.previousSibling;
}
adjacentAnchor = lastTextNodeChild
adjacentAnchorOffset = adjacentAnchor?.nodeValue?.length ?? 0
} else {
adjacentAnchorOffset = 0
}
if (adjacentAnchor) {
range.setEnd(adjacentAnchor, adjacentAnchorOffset)
}
}
}
export type RangeEndPos = {
left: number
top: number
@ -246,6 +325,13 @@ export type RangeEndPos = {
height: number
}
/**
* Return coordinates of the screen area occupied by the last line of user selection
*
* @param range range of user selection
* @param getFirst whether to get first line of user selection. Get last if false (default)
* @returns {RangeEndPos} selection coordinates
*/
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 }