1- import type { EventHandler , KeyboardEvent , MouseEvent } from "react" ;
1+ import { type EventHandler , Fragment , type KeyboardEvent , type MouseEvent , useMemo } from "react" ;
22import "./Highlighter.css" ;
33import type { ISynctexBlock } from "@fluffylabs/links-metadata" ;
44
5- const DEFAULT_HIGHLIGHT_OPACITY = 0.2 ;
5+ const DEFAULT_HIGHLIGHT_OPACITY = 0.15 ;
66
77// arbitrarily set offset to match the manual selection of text in presentation overlay.
88const WIDTH_OFFSET = 5 ;
99// aribtrarily set offset to make sure that the entirety of the letters is selected.
1010const HEIGHT_OFFSET = 5 ;
1111
12- // We control the z-index manually, for two reasons:
13- // 1. selection highlight to be below the annotation/note highlight.
14- // 2. notes/tooltips to be on top of the annotations (see `HighlightNote.css`)
15- // 3. multiple notes annotations should be reversly ordered
16- const DEFAULT_ZINDEX = 3 ;
17-
1812export interface IHighlighterColor {
1913 r : number ;
2014 g : number ;
@@ -25,11 +19,10 @@ interface IHighlighterProps {
2519 blocks : ISynctexBlock [ ] ;
2620 pageOffset : DOMRect ;
2721 color : IHighlighterColor ;
28- opacity ?: number ;
22+ onClick ?: EventHandler < MouseEvent < unknown > | KeyboardEvent < unknown > > ;
2923 onMouseEnter ?: ( ) => void ;
3024 onMouseLeave ?: ( ) => void ;
31- onClick ?: EventHandler < MouseEvent < unknown > | KeyboardEvent < unknown > > ;
32- zIndex ?: number ;
25+ opacity ?: number ;
3326}
3427
3528export function Highlighter ( {
@@ -40,25 +33,100 @@ export function Highlighter({
4033 onMouseEnter,
4134 onMouseLeave,
4235 opacity = DEFAULT_HIGHLIGHT_OPACITY ,
43- zIndex = DEFAULT_ZINDEX ,
4436} : IHighlighterProps ) {
45- return blocks . map ( ( block ) => (
46- < div
47- className = "highlighter-highlight"
48- onClick = { onClick }
49- onKeyPress = { onClick }
50- onMouseEnter = { onMouseEnter }
51- onMouseLeave = { onMouseLeave }
52- style = { {
53- // move active highlights on top, so they can be closed
54- zIndex,
55- left : `${ pageOffset . left + pageOffset . width * block . left } px` ,
56- top : `${ pageOffset . top + pageOffset . height * block . top - pageOffset . height * block . height } px` ,
57- width : `${ pageOffset . width * block . width + WIDTH_OFFSET } px` ,
58- height : `${ pageOffset . height * block . height + HEIGHT_OFFSET } px` ,
59- backgroundColor : `rgba(${ color . r } , ${ color . g } , ${ color . b } , ${ opacity } )` ,
60- } }
61- key = { `${ block . pageNumber } ,${ block . index } ` }
62- />
63- ) ) ;
37+ const nonOverlappingBlocks = useMemo ( ( ) => {
38+ const newBlocks = [ ] ;
39+ const blocksAndPositions = blocks . map ( ( block ) => ( { block, position : getBlockRect ( pageOffset , block ) } ) ) ;
40+ for ( const { block, position } of blocksAndPositions ) {
41+ let isContainedWithinSomeOther = false ;
42+ for ( const other of blocksAndPositions ) {
43+ // don't compare with self
44+ if ( position === other . position ) {
45+ continue ;
46+ }
47+ // skip if the current block is not contained in other
48+ if ( ! isContainedWithin ( position , other . position ) ) {
49+ continue ;
50+ }
51+ // break early if we found we are inside another block
52+ isContainedWithinSomeOther = true ;
53+ break ;
54+ }
55+ if ( ! isContainedWithinSomeOther ) {
56+ newBlocks . push ( block ) ;
57+ }
58+ }
59+ return newBlocks ;
60+ } , [ pageOffset , blocks ] ) ;
61+
62+ const lowestBlock = nonOverlappingBlocks . reduce ( ( a , b ) => {
63+ return a . top > b . top ? a : b ;
64+ } ) ;
65+
66+ return nonOverlappingBlocks . map ( ( block ) => {
67+ const position = getBlockRect ( pageOffset , block ) ;
68+ const isLeftColumn = position . left + position . width < pageOffset . left + pageOffset . width / 2 ;
69+
70+ const hasTrigger = onClick && block === lowestBlock ;
71+ const blockStyles = {
72+ left : `${ position . left } px` ,
73+ top : `${ position . top } px` ,
74+ width : `${ position . width } px` ,
75+ height : `${ position . height } px` ,
76+ backgroundColor : `rgba(${ color . r } , ${ color . g } , ${ color . b } , ${ opacity } )` ,
77+ } ;
78+
79+ return (
80+ < Fragment key = { `${ block . pageNumber } ,${ block . index } ` } >
81+ < div className = "highlighter-highlight" style = { blockStyles } />
82+ { hasTrigger && (
83+ < div
84+ className = "highlighter-trigger"
85+ onClick = { onClick }
86+ onKeyPress = { onClick }
87+ onMouseEnter = { onMouseEnter }
88+ onMouseLeave = { onMouseLeave }
89+ style = { {
90+ ...blockStyles ,
91+ // move the trigger on top of the textLayer and highlight.
92+ zIndex : 3 ,
93+ left : `${ isLeftColumn ? position . left - WIDTH_OFFSET : position . left + position . width - WIDTH_OFFSET } px` ,
94+ top : `${ position . top + position . height - HEIGHT_OFFSET } px` ,
95+ width : "15px" ,
96+ backgroundColor : "initial" ,
97+ opacity : 0.4 ,
98+ } }
99+ >
100+ 📍
101+ </ div >
102+ ) }
103+ </ Fragment >
104+ ) ;
105+ } ) ;
106+ }
107+
108+ function getBlockRect ( pageOffset : DOMRect , block : ISynctexBlock ) {
109+ const top = pageOffset . top + pageOffset . height * block . top - pageOffset . height * block . height ;
110+ const left = pageOffset . left + pageOffset . width * block . left ;
111+ const width = pageOffset . width * block . width + WIDTH_OFFSET ;
112+ const height = pageOffset . height * block . height + HEIGHT_OFFSET ;
113+
114+ return {
115+ top,
116+ left,
117+ width,
118+ height,
119+ } ;
120+ }
121+
122+ type Position = ReturnType < typeof getBlockRect > ;
123+
124+ function isContainedWithin ( a : Position , b : Position ) {
125+ const OVERLAP_OFFSET = 5 ;
126+ return (
127+ a . top >= b . top - OVERLAP_OFFSET &&
128+ a . left >= b . left - OVERLAP_OFFSET &&
129+ a . top + a . height <= b . top + b . height + OVERLAP_OFFSET &&
130+ a . left + a . width <= b . left + b . width + OVERLAP_OFFSET
131+ ) ;
64132}
0 commit comments