Skip to content

Commit 5c8d6e6

Browse files
authored
Make highlights less annoying. (#188)
1 parent 638097e commit 5c8d6e6

File tree

6 files changed

+120
-43
lines changed

6 files changed

+120
-43
lines changed

src/App.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
flex-direction: column-reverse;
3535
opacity: 0.7;
3636
transition: opacity ease-in 250ms;
37+
z-index: 10;
3738
}
3839
@media (max-width: 768px) {
3940
.controls {
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
11
.highlighter-highlight {
2+
position: absolute;
3+
pointer-events: none;
4+
}
5+
.highlighter-trigger {
26
position: absolute;
37
cursor: pointer;
48
}
9+
.highlighter-trigger:hover {
10+
transition: opacity 200ms ease-in-out;
11+
opacity: 1.0 !important;
12+
}
Lines changed: 99 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,14 @@
1-
import type { EventHandler, KeyboardEvent, MouseEvent } from "react";
1+
import { type EventHandler, Fragment, type KeyboardEvent, type MouseEvent, useMemo } from "react";
22
import "./Highlighter.css";
33
import 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.
88
const WIDTH_OFFSET = 5;
99
// aribtrarily set offset to make sure that the entirety of the letters is selected.
1010
const 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-
1812
export 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

3528
export 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
}

src/components/PdfViewer/NoteRenderer/components/HighlightNote/HighlightNote.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ interface HighlightNoteProps {
1414
}
1515

1616
const NOTE_COLOR = { r: 200, g: 200, b: 0 };
17-
const NOTE_OPACITY = 0.5;
17+
const NOTE_OPACITY = 0.3;
1818
const HOVER_OFF_DELAY_MS = 350;
1919

2020
export function HighlightNote({ notes, pageOffset, isInViewport, isPinnedByDefault }: HighlightNoteProps) {
@@ -86,7 +86,7 @@ export function HighlightNote({ notes, pageOffset, isInViewport, isPinnedByDefau
8686
// We should rather display one highlight and have it open both notes,
8787
// or be able to select which note to open.
8888
return (
89-
<div>
89+
<>
9090
<Highlighter
9191
blocks={blocks}
9292
pageOffset={pageOffset}
@@ -116,6 +116,6 @@ export function HighlightNote({ notes, pageOffset, isInViewport, isPinnedByDefau
116116
</Fragment>
117117
))}
118118
</div>
119-
</div>
119+
</>
120120
);
121121
}

src/components/PdfViewer/PdfViewer.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@
2525
filter: invert(1);
2626
}
2727

28+
.pdfViewer .textLayer {
29+
/* Bump the z-index of text layer, so that we can display highlights
30+
* between the page background and the text layer (selection is on top)
31+
*/
32+
z-index: 1;
33+
}
34+
2835
.pdfViewer.gray .textLayer ::selection,
2936
.pdfViewer.light .textLayer ::selection {
3037
background: rgba(255, 155, 50, 0.25);

src/components/PdfViewer/SelectionRenderer/SelectionRenderer.tsx

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@ import { Highlighter, type IHighlighterColor } from "../Highlighter/Highlighter"
77
import { useTextLayer } from "../utils";
88

99
const SELECTION_COLOR: IHighlighterColor = { r: 0, g: 100, b: 200 };
10-
const SELECTION_OPACITY = 0.5;
11-
const SELECTION_ZINDEX = 2;
10+
const SELECTION_OPACITY = 0.3;
1211
const SCROLL_TO_OFFSET_PX: number = 200;
1312

1413
export function SelectionRenderer() {
@@ -114,13 +113,7 @@ export function SelectionRenderer() {
114113
if (!viewer || !pageOffset) return null;
115114

116115
return (
117-
<Highlighter
118-
blocks={selectedBlocks}
119-
pageOffset={pageOffset}
120-
color={SELECTION_COLOR}
121-
opacity={SELECTION_OPACITY}
122-
zIndex={SELECTION_ZINDEX}
123-
/>
116+
<Highlighter blocks={selectedBlocks} pageOffset={pageOffset} color={SELECTION_COLOR} opacity={SELECTION_OPACITY} />
124117
);
125118
}
126119

0 commit comments

Comments
 (0)