Skip to content

Commit 54c4cee

Browse files
authored
Handle links from JAM search (#236)
* Fix version switching #197 * Scroll to section and highlight search from URL. * Fix build. * Rename processed params. Fix issue with section. * Switch to search tab when search is in URL. * Add placeholder.
1 parent 6a4d4a8 commit 54c4cee

File tree

6 files changed

+184
-40
lines changed

6 files changed

+184
-40
lines changed

src/components/LocationProvider/LocationProvider.tsx

Lines changed: 97 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export interface ILocationContext {
1111

1212
interface ILocationParams extends Partial<ISelectionParams> {
1313
version: string;
14+
search?: string;
15+
section?: string;
1416
}
1517

1618
interface ILocationProviderProps {
@@ -43,10 +45,11 @@ export function LocationProvider({ children }: ILocationProviderProps) {
4345

4446
const handleSetLocationParams = useCallback(
4547
(newParams?: ILocationParams) => {
46-
const version =
47-
newParams?.version.substring(0, SHORT_COMMIT_HASH_LENGTH) ||
48-
metadata.versions[metadata.latest]?.hash.substring(0, SHORT_COMMIT_HASH_LENGTH);
49-
const versionName = newParams ? metadata.versions[newParams.version]?.name : undefined;
48+
const fullVersion = newParams?.version;
49+
const version = fullVersion
50+
? fullVersion.substring(0, SHORT_COMMIT_HASH_LENGTH)
51+
: metadata.versions[metadata.latest]?.hash.substring(0, SHORT_COMMIT_HASH_LENGTH);
52+
const versionName = fullVersion ? metadata.versions[fullVersion]?.name : metadata.versions[metadata.latest]?.name;
5053

5154
const stringifiedParams = [];
5255

@@ -59,54 +62,83 @@ export function LocationProvider({ children }: ILocationProviderProps) {
5962
].join("");
6063
}
6164

62-
const newHash = `${SEGMENT_SEPARATOR}${stringifiedParams.join(SEGMENT_SEPARATOR)}`;
63-
window.location.hash = versionName ? `${newHash}?v=${versionName}` : newHash;
65+
// we never put search/section to the URL,
66+
// yet we keep them in `locationParams`.
67+
const params: SearchParams = {
68+
v: versionName,
69+
rest: `${SEGMENT_SEPARATOR}${stringifiedParams.join(SEGMENT_SEPARATOR)}`,
70+
};
71+
window.location.hash = serializeSearchParams(params);
6472
},
6573
[metadata],
6674
);
6775

6876
const handleHashChange = useCallback(() => {
69-
const newHash = window.location.hash.substring(1);
70-
71-
if (!newHash || !newHash.startsWith(SEGMENT_SEPARATOR)) {
72-
handleSetLocationParams();
77+
const { rest: newHash, search, section } = extractSearchParams(window.location.hash);
78+
79+
if (!newHash.startsWith(SEGMENT_SEPARATOR)) {
80+
const version = metadata.latest;
81+
setLocationParams((params) => ({
82+
...params,
83+
version,
84+
search,
85+
section,
86+
}));
87+
handleSetLocationParams({ version, search, section });
7388
return;
7489
}
7590

7691
const rawParams = newHash.split(SEGMENT_SEPARATOR).slice(1);
92+
const selectedVersion = rawParams[VERSION_SEGMENT_INDEX];
7793

78-
const fullVersion = Object.keys(metadata.versions).find((version) =>
79-
version.startsWith(rawParams[VERSION_SEGMENT_INDEX]),
80-
);
94+
const fullVersion =
95+
selectedVersion.length > 0
96+
? Object.keys(metadata.versions).find((version) => version.startsWith(rawParams[VERSION_SEGMENT_INDEX]))
97+
: null;
8198

8299
if (!fullVersion) {
83-
handleSetLocationParams();
100+
const version = metadata.latest;
101+
setLocationParams((params) => ({
102+
...params,
103+
version,
104+
search,
105+
section,
106+
}));
107+
handleSetLocationParams({ version, search, section });
84108
return;
85109
}
86110

87-
const processedParams: ILocationParams = {
111+
const newLocationParams: ILocationParams = {
88112
version: fullVersion,
113+
search,
114+
section,
89115
};
90116

91117
if (rawParams[SELECTION_SEGMENT_INDEX]) {
92118
const matchedHexSegments = [...rawParams[SELECTION_SEGMENT_INDEX].matchAll(SELECTION_DECOMPOSE_PATTERN)];
93119

94120
if (matchedHexSegments.length === 2) {
95-
processedParams.selectionStart = decodePageNumberAndIndex(matchedHexSegments[0][0]);
96-
processedParams.selectionEnd = decodePageNumberAndIndex(matchedHexSegments[1][0]);
121+
newLocationParams.selectionStart = decodePageNumberAndIndex(matchedHexSegments[0][0]);
122+
newLocationParams.selectionEnd = decodePageNumberAndIndex(matchedHexSegments[1][0]);
97123
}
98124
}
99125

100126
// Update location but only if it has REALLY changed.
101127
setLocationParams((params) => {
102-
if (!isSameBlock(params?.selectionStart, processedParams.selectionStart)) {
103-
return processedParams;
128+
if (!isSameBlock(params?.selectionStart, newLocationParams.selectionStart)) {
129+
return newLocationParams;
130+
}
131+
if (!isSameBlock(params?.selectionEnd, newLocationParams.selectionEnd)) {
132+
return newLocationParams;
104133
}
105-
if (!isSameBlock(params?.selectionEnd, processedParams.selectionEnd)) {
106-
return processedParams;
134+
if (params?.version !== newLocationParams.version) {
135+
return newLocationParams;
107136
}
108-
if (params?.version !== processedParams.version) {
109-
return processedParams;
137+
if (params?.search !== newLocationParams.search) {
138+
return newLocationParams;
139+
}
140+
if (params?.section !== newLocationParams.section) {
141+
return newLocationParams;
110142
}
111143
return params;
112144
});
@@ -174,3 +206,45 @@ function decodePageNumberAndIndex(s: string) {
174206
index += fromHex(s.substring(4, 6)) << 8;
175207
return { pageNumber, index };
176208
}
209+
210+
type SearchParams = {
211+
rest: string;
212+
v?: string;
213+
search?: string;
214+
section?: string;
215+
};
216+
217+
function extractSearchParams(hash: string): SearchParams {
218+
// skip the leading '/'
219+
const [rest, searchParams] = hash.substring(1).split("?");
220+
221+
const result = {
222+
rest,
223+
v: undefined,
224+
search: undefined,
225+
section: undefined,
226+
};
227+
228+
if (!searchParams) {
229+
return result;
230+
}
231+
232+
for (const v of searchParams.split("&")) {
233+
const [key, val] = v.split("=");
234+
if (key in result) {
235+
(result as { [key: string]: string | undefined })[key] = decodeURIComponent(val);
236+
}
237+
}
238+
239+
return result;
240+
}
241+
function serializeSearchParams({ rest, ...searchParams }: SearchParams) {
242+
const search = [];
243+
for (const key of Object.keys(searchParams)) {
244+
const val = searchParams[key as keyof typeof searchParams];
245+
if (val) {
246+
search.push(`${key}=${encodeURIComponent(val)}`);
247+
}
248+
}
249+
return `${rest}${search.length > 0 ? `?${search.join("&")}` : ""}`;
250+
}

src/components/Outline/Outline.tsx

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,47 @@
11
import "./Outline.css";
22
import type { PDFDocumentProxy } from "pdfjs-dist";
33
import { type ReactNode, useCallback, useContext, useEffect, useState } from "react";
4+
import { type ILocationContext, LocationContext } from "../LocationProvider/LocationProvider";
45
import { PdfContext } from "../PdfProvider/PdfProvider";
56
import type { IPdfContext } from "../PdfProvider/PdfProvider";
67

78
export type TOutline = Awaited<ReturnType<PDFDocumentProxy["getOutline"]>>;
89

9-
export function Outline() {
10+
export function Outline({ searchIsDone }: { searchIsDone: boolean }) {
11+
const { locationParams } = useContext(LocationContext) as ILocationContext;
12+
const { pdfDocument, linkService } = useContext(PdfContext) as IPdfContext;
1013
const [outline, setOutline] = useState<TOutline>([]);
11-
const { pdfDocument } = useContext(PdfContext) as IPdfContext;
1214

13-
// perform one-time operations.
15+
// Load the outline
1416
useEffect(() => {
1517
pdfDocument?.getOutline().then((outline) => setOutline(outline));
1618
}, [pdfDocument]);
1719

20+
// scroll to section
21+
const section = locationParams.section?.toLowerCase();
22+
useEffect(() => {
23+
if (section === undefined || searchIsDone === false) {
24+
return;
25+
}
26+
const findItem = (outline: TOutline): TOutline[0] | undefined => {
27+
for (const item of outline) {
28+
if (item.title.toLowerCase().includes(section)) {
29+
return item;
30+
}
31+
const res = findItem(item.items);
32+
if (res !== undefined) {
33+
return res;
34+
}
35+
}
36+
return undefined;
37+
};
38+
39+
const itemToScrollTo = findItem(outline);
40+
if (itemToScrollTo?.dest) {
41+
linkService?.goToDestination(itemToScrollTo.dest);
42+
}
43+
}, [searchIsDone, section, outline, linkService]);
44+
1845
const renderOutline = (outline: TOutline) => {
1946
return (
2047
<ul>

src/components/Search/Search.tsx

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,33 @@
11
import { useCallback, useContext, useEffect, useRef, useState } from "react";
2+
import { type ILocationContext, LocationContext } from "../LocationProvider/LocationProvider";
23
import { type IPdfContext, PdfContext } from "../PdfProvider/PdfProvider";
34

45
import "./Search.css";
56

6-
export function Search({ query, setQuery }: { query: string; setQuery: (x: string) => void }) {
7+
export function Search({ onSearchFinished }: { onSearchFinished: () => void }) {
8+
const { locationParams } = useContext(LocationContext) as ILocationContext;
9+
const [query, setQuery] = useState("");
10+
// search query is persistent between tab switches
11+
// and we also handle search input from URL.
12+
const search = locationParams.search;
13+
useEffect(() => {
14+
if (search) {
15+
setQuery(search);
16+
} else {
17+
onSearchFinished();
18+
}
19+
}, [search, onSearchFinished]);
20+
721
return (
822
<div className="search-wrapper">
923
<input
1024
autoFocus
1125
type="text"
1226
value={query}
1327
onChange={(e) => setQuery(e.target.value)}
14-
placeholder="🔍 search the Gray Paper"
28+
placeholder="🔍 press 's' to search the Gray Paper"
1529
/>
16-
<SearchResults query={query} />
30+
<SearchResults query={query} onSearchFinished={onSearchFinished} />
1731
</div>
1832
);
1933
}
@@ -31,16 +45,18 @@ type PageResults = {
3145

3246
type SearchResultsProps = {
3347
query: string;
48+
onSearchFinished: () => void;
3449
};
3550

36-
function SearchResults({ query }: SearchResultsProps) {
51+
function SearchResults({ query, onSearchFinished }: SearchResultsProps) {
3752
const { eventBus, findController, viewer, linkService } = useContext(PdfContext) as IPdfContext;
3853
const [isLoading, setIsLoading] = useState(false);
3954
const resetTimeout = useRef(0);
4055
const [matches, setMatches] = useState<Match>({
4156
count: 0,
4257
pagesAndCount: [],
4358
});
59+
4460
const resetMatchesLater = useCallback(() => {
4561
setIsLoading(true);
4662
clearTimeout(resetTimeout.current);
@@ -84,7 +100,7 @@ function SearchResults({ query }: SearchResultsProps) {
84100
const updateMatches = () => {
85101
const count = pageMatches.reduce((sum, x) => sum + x.length, 0);
86102
const pagesAndCount = Array.from(pageMatches.entries())
87-
.filter((x) => x[1].length > 0)
103+
.filter((x) => x.length > 0 && x[1].length > 0)
88104
.map(
89105
(x) =>
90106
({
@@ -96,13 +112,14 @@ function SearchResults({ query }: SearchResultsProps) {
96112
clearTimeout(resetTimeout.current);
97113
setIsLoading(false);
98114
setMatches({ count, pagesAndCount });
115+
onSearchFinished();
99116
};
100117

101118
eventBus.on("updatefindmatchescount", updateMatches);
102119
return () => {
103120
eventBus.off("updatefindmatchescount", updateMatches);
104121
};
105-
}, [eventBus, findController, viewer]);
122+
}, [eventBus, findController, viewer, onSearchFinished]);
106123

107124
const jumpToPage = useCallback(
108125
(res: PageResults) => {

src/components/Sidebar/Sidebar.tsx

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import "./Sidebar.css";
22

3-
import { useEffect, useState } from "react";
3+
import { useCallback, useEffect, useState } from "react";
44
import { NoteManager } from "../NoteManager/NoteManager";
55
import { Outline } from "../Outline/Outline";
66
import { Search } from "../Search/Search";
@@ -10,8 +10,6 @@ import { Version } from "../Version/Version";
1010

1111
export function Sidebar() {
1212
const [tab, setTab] = useState(loadActiveTab());
13-
// search query is persistent between tab switches
14-
const [query, setQuery] = useState("");
1513

1614
// store seletected tab in LS
1715
useEffect(() => {
@@ -22,7 +20,7 @@ export function Sidebar() {
2220
const handleKeyDown = (event: KeyboardEvent) => {
2321
const isTyping = document.activeElement?.tagName === "INPUT" || document.activeElement?.tagName === "TEXTAREA";
2422

25-
if (event.key === "s" && !event.ctrlKey && !event.metaKey && !event.altKey && !isTyping) {
23+
if (event.key.toLowerCase() === "s" && !event.ctrlKey && !event.metaKey && !event.altKey && !isTyping) {
2624
event.preventDefault();
2725
setTab("search");
2826
}
@@ -32,26 +30,34 @@ export function Sidebar() {
3230
return () => document.removeEventListener("keydown", handleKeyDown);
3331
}, []);
3432

33+
// if we have both search & section, we need to wait
34+
// for the search to be done, before scrolling to section.
35+
const [searchIsDone, setSearchIsDone] = useState(false);
36+
const onSearchFinished = useCallback(() => {
37+
setSearchIsDone(true);
38+
setTab("search");
39+
}, []);
40+
3541
const tabs = [
3642
{
3743
name: "outline",
38-
render: () => <Outline />,
44+
render: () => <Outline searchIsDone={searchIsDone} />,
3945
},
4046
{
4147
name: "notes",
4248
render: () => <NoteManager />,
4349
},
4450
{
4551
name: "search",
46-
render: () => <Search {...{ query, setQuery }} />,
52+
render: () => <Search onSearchFinished={onSearchFinished} />,
4753
},
4854
];
4955

5056
return (
5157
<div className="sidebar">
5258
<div className="content">
5359
<Selection activeTab={tab} switchTab={setTab} />
54-
<Tabs tabs={tabs} activeTab={tab} switchTab={setTab} />
60+
<Tabs tabs={tabs} activeTab={tab} switchTab={setTab} alwaysRender />
5561
<Version />
5662
</div>
5763
</div>

src/components/Tabs/Tabs.css

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,6 @@
3636
padding: 1rem;
3737
border-radius: 1rem 1rem 1rem 0;
3838
}
39+
.tabs .hidden {
40+
display: none;
41+
}

0 commit comments

Comments
 (0)