Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 85 additions & 15 deletions src/components/LocationProvider/LocationProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ export interface ILocationContext {
}

interface ILocationParams extends Partial<ISelectionParams> {
version: string;
version?: string;
search?: string;
section?: string;
}

interface ILocationProviderProps {
Expand Down Expand Up @@ -43,10 +45,11 @@ export function LocationProvider({ children }: ILocationProviderProps) {

const handleSetLocationParams = useCallback(
(newParams?: ILocationParams) => {
const version =
newParams?.version.substring(0, SHORT_COMMIT_HASH_LENGTH) ||
metadata.versions[metadata.latest]?.hash.substring(0, SHORT_COMMIT_HASH_LENGTH);
const versionName = newParams ? metadata.versions[newParams.version]?.name : undefined;
const fullVersion = newParams?.version;
const version = fullVersion
? fullVersion.substring(0, SHORT_COMMIT_HASH_LENGTH)
: metadata.versions[metadata.latest]?.hash.substring(0, SHORT_COMMIT_HASH_LENGTH);
const versionName = fullVersion ? metadata.versions[fullVersion]?.name : metadata.versions[metadata.latest]?.name;

const stringifiedParams = [];

Expand All @@ -59,33 +62,52 @@ export function LocationProvider({ children }: ILocationProviderProps) {
].join("");
}

const newHash = `${SEGMENT_SEPARATOR}${stringifiedParams.join(SEGMENT_SEPARATOR)}`;
window.location.hash = versionName ? `${newHash}?v=${versionName}` : newHash;
// we never put search/section to the URL,
// yet we keep them in `locationParams`.
const params: SearchParams = {
v: versionName,
rest: `${SEGMENT_SEPARATOR}${stringifiedParams.join(SEGMENT_SEPARATOR)}`,
};
window.location.hash = serializeSearchParams(params);
},
[metadata],
);

const handleHashChange = useCallback(() => {
const newHash = window.location.hash.substring(1);

if (!newHash || !newHash.startsWith(SEGMENT_SEPARATOR)) {
handleSetLocationParams();
const { rest: newHash, search, section } = extractSearchParams(window.location.hash);

if (!newHash.startsWith(SEGMENT_SEPARATOR)) {
setLocationParams((params) => ({
...params,
search,
section,
}));
handleSetLocationParams({ search, section });
return;
}

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

const fullVersion = Object.keys(metadata.versions).find((version) =>
version.startsWith(rawParams[VERSION_SEGMENT_INDEX]),
);
const fullVersion =
selectedVersion.length > 0
? Object.keys(metadata.versions).find((version) => version.startsWith(rawParams[VERSION_SEGMENT_INDEX]))
: null;

if (!fullVersion) {
handleSetLocationParams();
setLocationParams((params) => ({
...params,
search,
section,
}));
handleSetLocationParams({ search, section });
return;
}

const processedParams: ILocationParams = {
version: fullVersion,
search,
section,
};

if (rawParams[SELECTION_SEGMENT_INDEX]) {
Expand All @@ -108,6 +130,12 @@ export function LocationProvider({ children }: ILocationProviderProps) {
if (params?.version !== processedParams.version) {
return processedParams;
}
if (params?.search !== processedParams?.search) {
return processedParams;
}
if (params?.section !== undefined) {
return processedParams;
}
return params;
});
}, [handleSetLocationParams, metadata]);
Expand Down Expand Up @@ -174,3 +202,45 @@ function decodePageNumberAndIndex(s: string) {
index += fromHex(s.substring(4, 6)) << 8;
return { pageNumber, index };
}

type SearchParams = {
rest: string;
v?: string;
search?: string;
section?: string;
};

function extractSearchParams(hash: string): SearchParams {
// skip the leading '/'
const [rest, searchParams] = hash.substring(1).split("?");

const result = {
rest,
v: undefined,
search: undefined,
section: undefined,
};

if (!searchParams) {
return result;
}

for (const v of searchParams.split("&")) {
const [key, val] = v.split("=");
if (key in result) {
(result as { [key: string]: string | undefined })[key] = decodeURIComponent(val);
}
}

return result;
}
function serializeSearchParams({ rest, ...searchParams }: SearchParams) {
const search = [];
for (const key of Object.keys(searchParams)) {
const val = searchParams[key as keyof typeof searchParams];
if (val) {
search.push(`${key}=${encodeURIComponent(val)}`);
}
}
return `${rest}${search.length > 0 ? `?${search.join("&")}` : ""}`;
}
2 changes: 1 addition & 1 deletion src/components/NoteManager/NoteManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
author: DEFAULT_AUTHOR,
selectionStart: locationParams.selectionStart,
selectionEnd: locationParams.selectionEnd,
version: locationParams.version,
version: locationParams.fullVersion,

Check failure on line 58 in src/components/NoteManager/NoteManager.tsx

View workflow job for this annotation

GitHub Actions / build

Property 'fullVersion' does not exist on type 'ILocationParams'.
labels: [LABEL_LOCAL],
};

Expand Down
33 changes: 30 additions & 3 deletions src/components/Outline/Outline.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,47 @@
import "./Outline.css";
import type { PDFDocumentProxy } from "pdfjs-dist";
import { type ReactNode, useCallback, useContext, useEffect, useState } from "react";
import { type ILocationContext, LocationContext } from "../LocationProvider/LocationProvider";
import { PdfContext } from "../PdfProvider/PdfProvider";
import type { IPdfContext } from "../PdfProvider/PdfProvider";

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

export function Outline() {
export function Outline({ searchIsDone }: { searchIsDone: boolean }) {
const { locationParams } = useContext(LocationContext) as ILocationContext;
const { pdfDocument, linkService } = useContext(PdfContext) as IPdfContext;
const [outline, setOutline] = useState<TOutline>([]);
const { pdfDocument } = useContext(PdfContext) as IPdfContext;

// perform one-time operations.
// Load the outline
useEffect(() => {
pdfDocument?.getOutline().then((outline) => setOutline(outline));
}, [pdfDocument]);

// scroll to section
const section = locationParams.section?.toLowerCase();
useEffect(() => {
if (section === undefined || searchIsDone === false) {
return;
}
const findItem = (outline: TOutline): TOutline[0] | undefined => {
for (const item of outline) {
if (item.title.toLowerCase().includes(section)) {
return item;
}
const res = findItem(item.items);
if (res !== undefined) {
return res;
}
}
return undefined;
};

const itemToScrollTo = findItem(outline);
if (itemToScrollTo?.dest) {
linkService?.goToDestination(itemToScrollTo.dest);
}
}, [searchIsDone, section, outline, linkService]);

const renderOutline = (outline: TOutline) => {
return (
<ul>
Expand Down
27 changes: 22 additions & 5 deletions src/components/Search/Search.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
import { useCallback, useContext, useEffect, useRef, useState } from "react";
import { type ILocationContext, LocationContext } from "../LocationProvider/LocationProvider";
import { type IPdfContext, PdfContext } from "../PdfProvider/PdfProvider";

import "./Search.css";

export function Search({ query, setQuery }: { query: string; setQuery: (x: string) => void }) {
export function Search({ onSearchFinished }: { onSearchFinished: () => void }) {
const { locationParams } = useContext(LocationContext) as ILocationContext;
const [query, setQuery] = useState("");
// search query is persistent between tab switches
// and we also handle search input from URL.
const search = locationParams.search;
useEffect(() => {
if (search) {
setQuery(search);
} else {
onSearchFinished();
}
}, [search, onSearchFinished]);

return (
<div className="search-wrapper">
<input
Expand All @@ -13,7 +27,7 @@ export function Search({ query, setQuery }: { query: string; setQuery: (x: strin
onChange={(e) => setQuery(e.target.value)}
placeholder="🔍 search the Gray Paper"
/>
<SearchResults query={query} />
<SearchResults query={query} onSearchFinished={onSearchFinished} />
</div>
);
}
Expand All @@ -31,16 +45,18 @@ type PageResults = {

type SearchResultsProps = {
query: string;
onSearchFinished: () => void;
};

function SearchResults({ query }: SearchResultsProps) {
function SearchResults({ query, onSearchFinished }: SearchResultsProps) {
const { eventBus, findController, viewer, linkService } = useContext(PdfContext) as IPdfContext;
const [isLoading, setIsLoading] = useState(false);
const resetTimeout = useRef(0);
const [matches, setMatches] = useState<Match>({
count: 0,
pagesAndCount: [],
});

const resetMatchesLater = useCallback(() => {
setIsLoading(true);
clearTimeout(resetTimeout.current);
Expand Down Expand Up @@ -84,7 +100,7 @@ function SearchResults({ query }: SearchResultsProps) {
const updateMatches = () => {
const count = pageMatches.reduce((sum, x) => sum + x.length, 0);
const pagesAndCount = Array.from(pageMatches.entries())
.filter((x) => x[1].length > 0)
.filter((x) => x.length > 0 && x[1].length > 0)
.map(
(x) =>
({
Expand All @@ -96,13 +112,14 @@ function SearchResults({ query }: SearchResultsProps) {
clearTimeout(resetTimeout.current);
setIsLoading(false);
setMatches({ count, pagesAndCount });
onSearchFinished();
};

eventBus.on("updatefindmatchescount", updateMatches);
return () => {
eventBus.off("updatefindmatchescount", updateMatches);
};
}, [eventBus, findController, viewer]);
}, [eventBus, findController, viewer, onSearchFinished]);

const jumpToPage = useCallback(
(res: PageResults) => {
Expand Down
17 changes: 11 additions & 6 deletions src/components/Sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import "./Sidebar.css";

import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { NoteManager } from "../NoteManager/NoteManager";
import { Outline } from "../Outline/Outline";
import { Search } from "../Search/Search";
Expand All @@ -10,8 +10,6 @@ import { Version } from "../Version/Version";

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

// store seletected tab in LS
useEffect(() => {
Expand All @@ -32,26 +30,33 @@ export function Sidebar() {
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);

// if we have both search & section, we need to wait
// for the search to be done, before scrolling to section.
const [searchIsDone, setSearchIsDone] = useState(false);
const onSearchFinished = useCallback(() => {
setSearchIsDone(true);
}, []);

const tabs = [
{
name: "outline",
render: () => <Outline />,
render: () => <Outline searchIsDone={searchIsDone} />,
},
{
name: "notes",
render: () => <NoteManager />,
},
{
name: "search",
render: () => <Search {...{ query, setQuery }} />,
render: () => <Search onSearchFinished={onSearchFinished} />,
},
];

return (
<div className="sidebar">
<div className="content">
<Selection activeTab={tab} switchTab={setTab} />
<Tabs tabs={tabs} activeTab={tab} switchTab={setTab} />
<Tabs tabs={tabs} activeTab={tab} switchTab={setTab} alwaysRender />
<Version />
</div>
</div>
Expand Down
3 changes: 3 additions & 0 deletions src/components/Tabs/Tabs.css
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,6 @@
padding: 1rem;
border-radius: 1rem 1rem 1rem 0;
}
.tabs .hidden {
display: none;
}
19 changes: 18 additions & 1 deletion src/components/Tabs/Tabs.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import React from "react";
import "./Tabs.css";
import type { ReactNode } from "react";

Expand All @@ -10,9 +11,11 @@ type TabsProps = {
tabs: Tab[];
activeTab: string;
switchTab: (v: string) => void;
/** Always render the components and just change visibility. */
alwaysRender: boolean;
};

export function Tabs({ tabs, activeTab, switchTab }: TabsProps) {
export function Tabs({ tabs, activeTab, switchTab, alwaysRender }: TabsProps) {
if (tabs.length === 0) {
return null;
}
Expand All @@ -24,6 +27,20 @@ export function Tabs({ tabs, activeTab, switchTab }: TabsProps) {
));

const activeTabIdx = tabs.map((t) => t.name).indexOf(activeTab);
if (alwaysRender) {
return (
<div className="tabs">
{tabs.map((tab, idx) => {
return (
<React.Fragment key={tab.name}>
<div className={idx === activeTabIdx ? "content" : "hidden"}>{tab.render()}</div>
</React.Fragment>
);
})}
<div className="menu">{actions}</div>
</div>
);
}
return (
<div className="tabs">
<div className="content">{tabs[activeTabIdx].render()}</div>
Expand Down
Loading