Skip to content

Commit cbc02c7

Browse files
authored
fix: improve click behaviour of single note
* fix: improve click behaviour of single note * fix: protect from unexpected undefined
1 parent a237ede commit cbc02c7

File tree

7 files changed

+130
-90
lines changed

7 files changed

+130
-90
lines changed

src/components/LocationProvider/LocationProvider.tsx

Lines changed: 22 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,27 @@ import { type ISelectionParams, type ISynctexBlock, isSameBlock } from "@fluffyl
22
import { type ReactNode, createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
33
import { deserializeLegacyLocation } from "../../utils/deserializeLegacyLocation";
44
import { type IMetadataContext, MetadataContext } from "../MetadataProvider/MetadataProvider";
5+
import type { ILocationParams, SearchParams } from "./types";
6+
import {
7+
BASE64_VALIDATION_REGEX,
8+
SEGMENT_SEPARATOR,
9+
SELECTION_DECOMPOSE_PATTERN,
10+
SELECTION_SEGMENT_INDEX,
11+
VERSION_SEGMENT_INDEX,
12+
} from "./utils/constants";
13+
import { locationParamsToHash } from "./utils/locationParamsToHash";
514

615
export interface ILocationContext {
716
locationParams: ILocationParams;
817
setLocationParams: (newParams: ILocationParams) => void;
918
synctexBlocksToSelectionParams: (blocks: ISynctexBlock[]) => ISelectionParams;
10-
}
11-
12-
interface ILocationParams extends Partial<ISelectionParams> {
13-
version: string;
14-
search?: string;
15-
section?: string;
19+
getHashFromLocationParams: (params: ILocationParams) => string;
1620
}
1721

1822
interface ILocationProviderProps {
1923
children: ReactNode;
2024
}
2125

22-
const VERSION_SEGMENT_INDEX = 0;
23-
const SELECTION_SEGMENT_INDEX = 1;
24-
const SEGMENT_SEPARATOR = "/";
25-
const SELECTION_DECOMPOSE_PATTERN = /[0-9A-F]{6}/gi;
26-
const SHORT_COMMIT_HASH_LENGTH = 7; // as many as git uses for `git rev-parse --short`
27-
const BASE64_VALIDATION_REGEX = /^#[-A-Za-z0-9+/]*={0,3}$/;
28-
2926
export const LocationContext = createContext<ILocationContext | null>(null);
3027

3128
export const useLocationContext = () => {
@@ -53,30 +50,17 @@ export function LocationProvider({ children }: ILocationProviderProps) {
5350

5451
const handleSetLocationParams = useCallback(
5552
(newParams?: ILocationParams) => {
56-
const fullVersion = newParams?.version;
57-
const version = fullVersion
58-
? fullVersion.substring(0, SHORT_COMMIT_HASH_LENGTH)
59-
: metadata.versions[metadata.latest]?.hash.substring(0, SHORT_COMMIT_HASH_LENGTH);
60-
const versionName = fullVersion ? metadata.versions[fullVersion]?.name : metadata.versions[metadata.latest]?.name;
61-
62-
const stringifiedParams = [];
63-
64-
stringifiedParams[VERSION_SEGMENT_INDEX] = version;
65-
66-
if (newParams?.selectionStart && newParams?.selectionEnd) {
67-
stringifiedParams[SELECTION_SEGMENT_INDEX] = [
68-
encodePageNumberAndIndex(newParams.selectionStart.pageNumber, newParams.selectionStart.index),
69-
encodePageNumberAndIndex(newParams.selectionEnd.pageNumber, newParams.selectionEnd.index),
70-
].join("");
71-
}
53+
if (!newParams) return;
54+
const hash = locationParamsToHash(newParams, metadata);
55+
window.location.hash = hash;
56+
},
57+
[metadata],
58+
);
7259

73-
// we never put search/section to the URL,
74-
// yet we keep them in `locationParams`.
75-
const params: SearchParams = {
76-
v: versionName,
77-
rest: `${SEGMENT_SEPARATOR}${stringifiedParams.join(SEGMENT_SEPARATOR)}`,
78-
};
79-
window.location.hash = serializeSearchParams(params);
60+
const getHashFromLocationParams = useCallback(
61+
(params: ILocationParams) => {
62+
const hash = locationParamsToHash(params, metadata);
63+
return hash;
8064
},
8165
[metadata],
8266
);
@@ -191,8 +175,9 @@ export function LocationProvider({ children }: ILocationProviderProps) {
191175
locationParams,
192176
setLocationParams: handleSetLocationParams,
193177
synctexBlocksToSelectionParams,
178+
getHashFromLocationParams,
194179
};
195-
}, [locationParams, handleSetLocationParams, synctexBlocksToSelectionParams]);
180+
}, [locationParams, handleSetLocationParams, synctexBlocksToSelectionParams, getHashFromLocationParams]);
196181

197182
if (!context) {
198183
return null;
@@ -201,11 +186,6 @@ export function LocationProvider({ children }: ILocationProviderProps) {
201186
return <LocationContext.Provider value={context}>{children}</LocationContext.Provider>;
202187
}
203188

204-
function encodePageNumberAndIndex(pageNumber: number, index: number) {
205-
const asHexByte = (num: number) => (num & 0xff).toString(16).padStart(2, "0");
206-
return `${asHexByte(pageNumber)}${asHexByte(index)}${asHexByte(index >> 8)}`;
207-
}
208-
209189
function decodePageNumberAndIndex(s: string) {
210190
if (s.length > 6) throw new Error("Pass exactly 6 hex characters");
211191
const fromHex = (s: string) => Number(`0x${s}`);
@@ -215,13 +195,6 @@ function decodePageNumberAndIndex(s: string) {
215195
return { pageNumber, index };
216196
}
217197

218-
type SearchParams = {
219-
rest: string;
220-
v?: string;
221-
search?: string;
222-
section?: string;
223-
};
224-
225198
function extractSearchParams(hash: string): SearchParams {
226199
// skip the leading '/'
227200
const [rest, searchParams] = hash.substring(1).split("?");
@@ -246,13 +219,3 @@ function extractSearchParams(hash: string): SearchParams {
246219

247220
return result;
248221
}
249-
function serializeSearchParams({ rest, ...searchParams }: SearchParams) {
250-
const search = [];
251-
for (const key of Object.keys(searchParams)) {
252-
const val = searchParams[key as keyof typeof searchParams];
253-
if (val) {
254-
search.push(`${key}=${encodeURIComponent(val)}`);
255-
}
256-
}
257-
return `${rest}${search.length > 0 ? `?${search.join("&")}` : ""}`;
258-
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { ISelectionParams } from "@fluffylabs/links-metadata";
2+
3+
export interface ILocationParams extends Partial<ISelectionParams> {
4+
version: string;
5+
search?: string;
6+
section?: string;
7+
}
8+
9+
export type SearchParams = {
10+
rest: string;
11+
v?: string;
12+
search?: string;
13+
section?: string;
14+
};
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export const VERSION_SEGMENT_INDEX = 0;
2+
export const SELECTION_SEGMENT_INDEX = 1;
3+
export const SEGMENT_SEPARATOR = "/";
4+
export const SELECTION_DECOMPOSE_PATTERN = /[0-9A-F]{6}/gi;
5+
export const SHORT_COMMIT_HASH_LENGTH = 7; // as many as git uses for `git rev-parse --short`
6+
export const BASE64_VALIDATION_REGEX = /^#[-A-Za-z0-9+/]*={0,3}$/;
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export function encodePageNumberAndIndex(pageNumber: number, index: number) {
2+
const asHexByte = (num: number) => (num & 0xff).toString(16).padStart(2, "0");
3+
return `${asHexByte(pageNumber)}${asHexByte(index)}${asHexByte(index >> 8)}`;
4+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { IMetadataContext } from "../../MetadataProvider/MetadataProvider";
2+
import type { ILocationParams, SearchParams } from "../types";
3+
import {
4+
SEGMENT_SEPARATOR,
5+
SELECTION_SEGMENT_INDEX,
6+
SHORT_COMMIT_HASH_LENGTH,
7+
VERSION_SEGMENT_INDEX,
8+
} from "./constants";
9+
import { encodePageNumberAndIndex } from "./encodePageNumberAndIndex";
10+
11+
export const locationParamsToHash = (params: ILocationParams, metadata: IMetadataContext["metadata"]) => {
12+
const fullVersion = params.version;
13+
const version = fullVersion
14+
? fullVersion.substring(0, SHORT_COMMIT_HASH_LENGTH)
15+
: metadata.versions[metadata.latest]?.hash.substring(0, SHORT_COMMIT_HASH_LENGTH) ?? "";
16+
const versionName =
17+
(fullVersion ? metadata.versions[fullVersion]?.name : metadata.versions[metadata.latest]?.name) ?? "";
18+
19+
const stringifiedParams = [];
20+
21+
stringifiedParams[VERSION_SEGMENT_INDEX] = version;
22+
23+
if (params.selectionStart && params.selectionEnd) {
24+
stringifiedParams[SELECTION_SEGMENT_INDEX] = [
25+
encodePageNumberAndIndex(params.selectionStart.pageNumber, params.selectionStart.index),
26+
encodePageNumberAndIndex(params.selectionEnd.pageNumber, params.selectionEnd.index),
27+
].join("");
28+
}
29+
30+
// we never put search/section to the URL,
31+
// yet we keep them in `locationParams`.
32+
const finalParamsToSerialize: SearchParams = {
33+
v: versionName,
34+
rest: `${SEGMENT_SEPARATOR}${stringifiedParams.join(SEGMENT_SEPARATOR)}`,
35+
};
36+
37+
return serializeSearchParams(finalParamsToSerialize);
38+
};
39+
40+
function serializeSearchParams({ rest, ...searchParams }: SearchParams) {
41+
const search = [];
42+
for (const key of Object.keys(searchParams)) {
43+
const val = searchParams[key as keyof typeof searchParams];
44+
if (val) {
45+
search.push(`${key}=${encodeURIComponent(val)}`);
46+
}
47+
}
48+
return `${rest}${search.length > 0 ? `?${search.join("&")}` : ""}`;
49+
}

src/components/NoteManager/components/Note.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export function Note({ note, active = false, onEditNote, onDeleteNote }: NotePro
5555
});
5656
const [noteContentError, setNoteContentError] = useState("");
5757

58-
const { setLocationParams } = useLocationContext();
58+
const { setLocationParams, locationParams } = useLocationContext();
5959

6060
const isEditable = note.source !== NoteSource.Remote;
6161

@@ -115,7 +115,7 @@ export function Note({ note, active = false, onEditNote, onDeleteNote }: NotePro
115115
}
116116

117117
setLocationParams({
118-
version: note.original.version,
118+
version: locationParams.version,
119119
selectionStart: note.original.selectionStart,
120120
selectionEnd: note.original.selectionEnd,
121121
});

src/components/NoteManager/components/NoteLink.tsx

Lines changed: 33 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { isSameBlock } from "@fluffylabs/links-metadata";
2-
import { type MouseEventHandler, useCallback, useContext, useEffect, useState } from "react";
2+
import { type MouseEventHandler, useCallback, useContext, useEffect, useMemo, useState } from "react";
33
import { Tooltip } from "react-tooltip";
44
import { CodeSyncContext, type ICodeSyncContext } from "../../CodeSyncProvider/CodeSyncProvider";
55
import { type ILocationContext, LocationContext } from "../../LocationProvider/LocationProvider";
@@ -22,7 +22,7 @@ export function NoteLink({ note, onEditNote }: NoteLinkProps) {
2222
const { getSectionTitleAtSynctexBlock, getSubsectionTitleAtSynctexBlock } = useContext(
2323
CodeSyncContext,
2424
) as ICodeSyncContext;
25-
const { setLocationParams, locationParams } = useContext(LocationContext) as ILocationContext;
25+
const { locationParams, getHashFromLocationParams } = useContext(LocationContext) as ILocationContext;
2626

2727
const migrationFlag = !note.current.isUpToDate;
2828
const isEditable = note.source !== NoteSource.Remote;
@@ -42,29 +42,12 @@ export function NoteLink({ note, onEditNote }: NoteLinkProps) {
4242
})();
4343
}, [selectionStart, getSectionTitleAtSynctexBlock, getSubsectionTitleAtSynctexBlock]);
4444

45-
const handleNoteTitleClick = useCallback<MouseEventHandler>(
46-
(e) => {
47-
e.preventDefault();
48-
setLocationParams({
49-
...locationParams,
50-
selectionStart,
51-
selectionEnd,
52-
});
53-
},
54-
[selectionStart, selectionEnd, locationParams, setLocationParams],
55-
);
56-
57-
const handleOriginalClick = useCallback<MouseEventHandler>(
58-
(e) => {
59-
e.preventDefault();
60-
setLocationParams({
61-
version: note.original.version,
62-
selectionStart: note.original.selectionStart,
63-
selectionEnd: note.original.selectionEnd,
64-
});
65-
},
66-
[note, setLocationParams],
67-
);
45+
const handleNoteLinkClick = useCallback<MouseEventHandler>((e) => {
46+
e.preventDefault();
47+
const href = e.currentTarget.getAttribute("href");
48+
if (!href) return;
49+
window.location.hash = href;
50+
}, []);
6851

6952
const handleMigrateClick = useCallback<MouseEventHandler>(
7053
(e) => {
@@ -90,32 +73,53 @@ export function NoteLink({ note, onEditNote }: NoteLinkProps) {
9073
[locationParams, note, selectionStart, selectionEnd, onEditNote],
9174
);
9275

76+
const currentVersionLink = useMemo(
77+
() =>
78+
getHashFromLocationParams({
79+
version: locationParams.version,
80+
selectionStart: note.original.selectionStart,
81+
selectionEnd: note.original.selectionEnd,
82+
}),
83+
[locationParams, note, getHashFromLocationParams],
84+
);
85+
86+
const originalLink = useMemo(
87+
() =>
88+
getHashFromLocationParams({
89+
version: note.original.version,
90+
selectionStart: note.original.selectionStart,
91+
selectionEnd: note.original.selectionEnd,
92+
}),
93+
[note, getHashFromLocationParams],
94+
);
95+
9396
const { section, subSection } = sectionTitle;
9497
return (
9598
<div className="note-link">
9699
{migrationFlag && (
97100
<a
98-
href="#"
101+
href={`#${originalLink}`}
99102
data-tooltip-id="note-link"
100103
data-tooltip-content="This note was created in a different version. Click here to see in original context."
101104
data-tooltip-place="top"
102105
className="icon default-link"
103-
onClick={handleOriginalClick}
106+
onClick={handleNoteLinkClick}
104107
>
105108
106109
</a>
107110
)}
108111

109112
<OutlineLink
113+
href={`#${currentVersionLink}`}
110114
firstLevel
111115
title={subSection ? `${section} > ${subSection}` : section}
112116
number={`p. ${pageNumber} >`}
113-
onClick={handleNoteTitleClick}
114-
href="#"
117+
onClick={handleNoteLinkClick}
115118
/>
116119

117120
{migrationFlag && isEditable && (
118121
<a
122+
href="#"
119123
onClick={handleMigrateClick}
120124
data-tooltip-id="note-link"
121125
data-tooltip-content="Make sure the selection is accurate or adjust it in the current version and update the note."

0 commit comments

Comments
 (0)