@peaske7/readit 0.1.7 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -3
- package/biome.json +1 -1
- package/bun.lock +43 -185
- package/docs/perf-baseline.md +75 -0
- package/docs/superpowers/plans/2026-03-26-surgical-pruning.md +1176 -0
- package/e2e/perf/add-comment.spec.ts +118 -0
- package/e2e/perf/fixtures/generate.ts +331 -0
- package/e2e/perf/initial-load.spec.ts +49 -0
- package/e2e/perf/perf.setup.ts +23 -0
- package/e2e/perf/perf.teardown.ts +9 -0
- package/e2e/perf/scroll.spec.ts +39 -0
- package/e2e/perf/tab-switch.spec.ts +69 -0
- package/e2e/perf/text-selection.spec.ts +119 -0
- package/e2e/perf/utils/metrics.ts +286 -0
- package/e2e/perf/utils/perf-cli.ts +86 -0
- package/package.json +9 -18
- package/playwright.config.ts +12 -0
- package/src/App.tsx +133 -178
- package/src/{cli/index.ts → cli.ts} +211 -107
- package/src/components/ActionsMenu.tsx +6 -27
- package/src/components/DocumentViewer/DocumentViewer.tsx +78 -105
- package/src/components/DocumentViewer/MermaidDiagram.tsx +6 -7
- package/src/components/Header.tsx +9 -20
- package/src/components/InlineEditor.tsx +5 -5
- package/src/components/MarginNote.tsx +71 -93
- package/src/components/MarginNotes.tsx +7 -34
- package/src/components/RawModal.tsx +9 -8
- package/src/components/ReanchorConfirm.tsx +2 -2
- package/src/components/SettingsModal.tsx +11 -89
- package/src/components/TabBar.tsx +4 -4
- package/src/components/TableOfContents.tsx +5 -5
- package/src/components/comments/CommentInput.tsx +7 -35
- package/src/components/comments/CommentListItem.tsx +9 -11
- package/src/components/comments/CommentManager.tsx +53 -37
- package/src/components/comments/CommentNav.tsx +14 -14
- package/src/components/ui/ActionLink.tsx +14 -18
- package/src/components/ui/Button.tsx +42 -43
- package/src/components/ui/Dialog.tsx +73 -113
- package/src/components/ui/DropdownMenu.tsx +113 -69
- package/src/components/ui/Text.tsx +30 -37
- package/src/contexts/CommentContext.tsx +75 -106
- package/src/contexts/LocaleContext.tsx +45 -4
- package/src/contexts/PositionsContext.tsx +16 -0
- package/src/contexts/SettingsContext.tsx +133 -0
- package/src/hooks/useClickOutside.ts +0 -4
- package/src/hooks/useCommentNavigation.ts +6 -29
- package/src/hooks/useComments.ts +6 -18
- package/src/hooks/useDocument.ts +35 -34
- package/src/hooks/useHeadings.test.ts +8 -50
- package/src/hooks/useHeadings.ts +5 -88
- package/src/hooks/useScrollSpy.ts +10 -14
- package/src/hooks/useTextSelection.ts +1 -38
- package/src/lib/__fixtures__/bench-data.ts +1 -41
- package/src/lib/anchor.bench.ts +57 -67
- package/src/lib/anchor.test.ts +5 -1
- package/src/lib/anchor.ts +13 -93
- package/src/lib/comment-storage.test.ts +4 -4
- package/src/lib/comment-storage.ts +2 -46
- package/src/lib/export.ts +7 -13
- package/src/lib/highlight/core.test.ts +1 -1
- package/src/lib/highlight/dom.ts +5 -68
- package/src/lib/highlight/highlighter.ts +102 -262
- package/src/lib/highlight/resolver.ts +112 -0
- package/src/lib/highlight/types.ts +0 -35
- package/src/lib/highlight/worker.ts +45 -0
- package/src/lib/i18n/en.ts +1 -50
- package/src/lib/i18n/ja.ts +1 -50
- package/src/lib/i18n/types.ts +1 -49
- package/src/lib/margin-layout.ts +5 -27
- package/src/lib/positions.ts +150 -0
- package/src/lib/utils.ts +2 -19
- package/src/schema.ts +81 -0
- package/src/{server/index.ts → server.ts} +111 -81
- package/src/{store/index.ts → store.ts} +14 -46
- package/vite.config.ts +8 -0
- package/src/components/DocumentViewer/IframeContainer.tsx +0 -251
- package/src/components/DocumentViewer/InlineCode.tsx +0 -60
- package/src/components/DocumentViewer/index.ts +0 -1
- package/src/components/FloatingTOC.tsx +0 -61
- package/src/components/ShortcutCapture.tsx +0 -48
- package/src/components/ShortcutList.tsx +0 -198
- package/src/components/comments/CommentMinimap.tsx +0 -62
- package/src/components/ui/ActionBar.tsx +0 -16
- package/src/components/ui/SeparatorDot.tsx +0 -9
- package/src/contexts/LayoutContext.tsx +0 -88
- package/src/hooks/useClipboard.ts +0 -82
- package/src/hooks/useEditorScheme.ts +0 -51
- package/src/hooks/useFontPreference.ts +0 -59
- package/src/hooks/useKeybindings.ts +0 -108
- package/src/hooks/useKeyboardShortcuts.ts +0 -63
- package/src/hooks/useLayoutMode.ts +0 -44
- package/src/hooks/useLocalePreference.ts +0 -42
- package/src/hooks/useReanchorMode.ts +0 -33
- package/src/hooks/useScrollMetrics.ts +0 -56
- package/src/hooks/useThemePreference.ts +0 -66
- package/src/lib/comment-storage.bench.ts +0 -63
- package/src/lib/context.bench.ts +0 -41
- package/src/lib/context.test.ts +0 -224
- package/src/lib/context.ts +0 -193
- package/src/lib/editor-links.ts +0 -59
- package/src/lib/export.bench.ts +0 -35
- package/src/lib/highlight/colors.ts +0 -37
- package/src/lib/highlight/core.ts +0 -54
- package/src/lib/highlight/index.ts +0 -23
- package/src/lib/highlight/script-builder.ts +0 -485
- package/src/lib/html-processor.test.tsx +0 -170
- package/src/lib/html-processor.tsx +0 -95
- package/src/lib/i18n/completeness.test.ts +0 -51
- package/src/lib/i18n/translations.test.ts +0 -39
- package/src/lib/layout-constants.ts +0 -12
- package/src/lib/margin-layout.bench.ts +0 -28
- package/src/lib/scroll.test.ts +0 -118
- package/src/lib/scroll.ts +0 -47
- package/src/lib/shortcut-registry.test.ts +0 -173
- package/src/lib/shortcut-registry.ts +0 -209
- package/src/lib/utils.test.ts +0 -110
- package/src/store/index.test.ts +0 -242
- package/src/types/index.ts +0 -127
|
@@ -1,54 +1,47 @@
|
|
|
1
|
-
import { Slot } from "@radix-ui/react-slot";
|
|
2
|
-
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
1
|
import { use } from "react";
|
|
4
|
-
import {
|
|
2
|
+
import { SettingsContext } from "../../contexts/SettingsContext";
|
|
5
3
|
import { cn } from "../../lib/utils";
|
|
6
|
-
import { FontFamilies } from "../../
|
|
4
|
+
import { FontFamilies } from "../../schema";
|
|
7
5
|
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
6
|
+
const variantStyles = {
|
|
7
|
+
title:
|
|
8
|
+
"text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-100",
|
|
9
|
+
section: "text-sm font-medium text-zinc-900 dark:text-zinc-100",
|
|
10
|
+
subsection: "text-xs font-medium text-zinc-700 dark:text-zinc-300",
|
|
11
|
+
overline:
|
|
12
|
+
"text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider",
|
|
13
|
+
body: "text-sm text-zinc-600 dark:text-zinc-400",
|
|
14
|
+
caption: "text-xs text-zinc-500 dark:text-zinc-400",
|
|
15
|
+
micro: "text-[10px] text-zinc-400 dark:text-zinc-500",
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
type TextVariant = keyof typeof variantStyles;
|
|
19
|
+
|
|
20
|
+
interface TextProps extends React.HTMLAttributes<HTMLElement> {
|
|
21
|
+
variant?: TextVariant;
|
|
22
|
+
as?: "p" | "span" | "div" | "h1" | "h2" | "h3" | "label" | "pre";
|
|
23
|
+
}
|
|
26
24
|
|
|
27
25
|
function Text({
|
|
28
26
|
className,
|
|
29
|
-
variant,
|
|
30
|
-
|
|
27
|
+
variant = "body",
|
|
28
|
+
as: Tag = "p",
|
|
31
29
|
...props
|
|
32
|
-
}:
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const layout = use(LayoutContext);
|
|
37
|
-
const fontClass = layout
|
|
38
|
-
? layout.fontFamily === FontFamilies.SANS_SERIF
|
|
30
|
+
}: TextProps) {
|
|
31
|
+
const settings = use(SettingsContext);
|
|
32
|
+
const fontClass = settings
|
|
33
|
+
? settings.fontFamily === FontFamilies.SANS_SERIF
|
|
39
34
|
? "font-sans"
|
|
40
35
|
: "font-serif"
|
|
41
36
|
: undefined;
|
|
42
37
|
|
|
43
|
-
const Comp = asChild ? Slot : "p";
|
|
44
|
-
|
|
45
38
|
return (
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
className={cn(fontClass, textVariants({ variant }), className)}
|
|
39
|
+
<Tag
|
|
40
|
+
className={cn(fontClass, variantStyles[variant], className)}
|
|
49
41
|
{...props}
|
|
50
42
|
/>
|
|
51
43
|
);
|
|
52
44
|
}
|
|
53
45
|
|
|
54
|
-
export {
|
|
46
|
+
export type { TextVariant };
|
|
47
|
+
export { Text, variantStyles };
|
|
@@ -9,18 +9,14 @@ import {
|
|
|
9
9
|
import { toast } from "sonner";
|
|
10
10
|
import { useCommentNavigation } from "../hooks/useCommentNavigation";
|
|
11
11
|
import { useComments } from "../hooks/useComments";
|
|
12
|
-
import {
|
|
13
|
-
import { extractContext, formatForLLM } from "../lib/context";
|
|
14
|
-
import { generatePrompt } from "../lib/export";
|
|
12
|
+
import { formatComment } from "../lib/export";
|
|
15
13
|
import { truncate } from "../lib/utils";
|
|
16
|
-
import {
|
|
17
|
-
import
|
|
14
|
+
import type { Comment } from "../schema";
|
|
15
|
+
import { appStore, useAppStore } from "../store";
|
|
18
16
|
import { useLocale } from "./LocaleContext";
|
|
19
17
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
comments: Comment[];
|
|
23
|
-
commentCount: number;
|
|
18
|
+
// Stable callbacks — never causes re-renders
|
|
19
|
+
interface CommentActionsValue {
|
|
24
20
|
addComment: (
|
|
25
21
|
selectedText: string,
|
|
26
22
|
comment: string,
|
|
@@ -36,52 +32,62 @@ interface CommentContextValue {
|
|
|
36
32
|
startOffset: number,
|
|
37
33
|
endOffset: number,
|
|
38
34
|
) => void;
|
|
39
|
-
// Derived
|
|
40
|
-
sortedComments: Comment[];
|
|
41
|
-
// From useCommentNavigation
|
|
42
|
-
currentIndex: number;
|
|
43
|
-
hoveredCommentId: string | undefined;
|
|
44
35
|
setHoveredCommentId: (id: string | undefined) => void;
|
|
45
36
|
navigateToComment: (commentId: string) => void;
|
|
46
37
|
navigatePrevious: () => void;
|
|
47
38
|
navigateNext: () => void;
|
|
48
|
-
// From useReanchorMode
|
|
49
|
-
reanchorTarget: { commentId: string } | null;
|
|
50
39
|
startReanchor: (commentId: string) => void;
|
|
51
40
|
cancelReanchor: () => void;
|
|
52
|
-
|
|
53
|
-
copyCommentRaw: (comment: Comment) => void;
|
|
54
|
-
copyCommentForLLM: (comment: Comment) => void;
|
|
55
|
-
copyAllForLLM: () => void;
|
|
56
|
-
// Scroll to highlight
|
|
41
|
+
copyComment: (comment: Comment) => void;
|
|
57
42
|
scrollToHighlight: (commentId: string) => void;
|
|
58
43
|
}
|
|
59
44
|
|
|
60
|
-
|
|
45
|
+
const CommentActionsContext = createContext<CommentActionsValue | null>(null);
|
|
61
46
|
|
|
62
|
-
export function
|
|
63
|
-
const value = use(
|
|
47
|
+
export function useCommentActions(): CommentActionsValue {
|
|
48
|
+
const value = use(CommentActionsContext);
|
|
64
49
|
if (!value) {
|
|
65
|
-
throw new Error("
|
|
50
|
+
throw new Error("useCommentActions must be used within a CommentProvider");
|
|
66
51
|
}
|
|
67
52
|
return value;
|
|
68
53
|
}
|
|
69
54
|
|
|
55
|
+
// Volatile — re-renders consumers on change
|
|
56
|
+
interface CommentDataValue {
|
|
57
|
+
comments: Comment[];
|
|
58
|
+
commentCount: number;
|
|
59
|
+
sortedComments: Comment[];
|
|
60
|
+
currentIndex: number;
|
|
61
|
+
reanchorTarget: { commentId: string } | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const CommentDataContext = createContext<CommentDataValue | null>(null);
|
|
65
|
+
|
|
66
|
+
export function useCommentData(): CommentDataValue {
|
|
67
|
+
const value = use(CommentDataContext);
|
|
68
|
+
if (!value) {
|
|
69
|
+
throw new Error("useCommentData must be used within a CommentProvider");
|
|
70
|
+
}
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type CommentContextValue = CommentActionsValue & CommentDataValue;
|
|
75
|
+
|
|
76
|
+
export function useCommentContext(): CommentContextValue {
|
|
77
|
+
return { ...useCommentActions(), ...useCommentData() };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const CommentContext = CommentDataContext;
|
|
81
|
+
|
|
70
82
|
interface CommentProviderProps {
|
|
71
83
|
filePath: string;
|
|
72
84
|
clean: boolean;
|
|
73
|
-
documentContent: string;
|
|
74
|
-
fileName: string;
|
|
75
|
-
documentType: DocumentType;
|
|
76
85
|
children: ReactNode;
|
|
77
86
|
}
|
|
78
87
|
|
|
79
88
|
export function CommentProvider({
|
|
80
89
|
filePath,
|
|
81
90
|
clean,
|
|
82
|
-
documentContent,
|
|
83
|
-
fileName,
|
|
84
|
-
documentType,
|
|
85
91
|
children,
|
|
86
92
|
}: CommentProviderProps) {
|
|
87
93
|
const {
|
|
@@ -94,136 +100,99 @@ export function CommentProvider({
|
|
|
94
100
|
reanchorComment,
|
|
95
101
|
} = useComments(filePath, { clean });
|
|
96
102
|
|
|
97
|
-
// sortedComments from store (already sorted by setComments)
|
|
98
103
|
const sortedComments = useAppStore(
|
|
99
104
|
(s) => s.documents.get(filePath)?.sortedComments ?? [],
|
|
100
105
|
);
|
|
101
106
|
|
|
102
107
|
const {
|
|
103
108
|
currentIndex,
|
|
104
|
-
hoveredCommentId,
|
|
105
109
|
setHoveredCommentId,
|
|
106
110
|
navigateToComment,
|
|
107
111
|
navigatePrevious,
|
|
108
112
|
navigateNext,
|
|
109
113
|
} = useCommentNavigation(sortedComments);
|
|
110
114
|
|
|
111
|
-
const
|
|
115
|
+
const reanchorTarget = useAppStore(
|
|
116
|
+
(s) => s.getActiveDocumentState()?.reanchorTarget ?? null,
|
|
117
|
+
);
|
|
118
|
+
const startReanchor = useCallback((commentId: string) => {
|
|
119
|
+
appStore.getState().setReanchorTarget({ commentId });
|
|
120
|
+
}, []);
|
|
121
|
+
const cancelReanchor = useCallback(() => {
|
|
122
|
+
appStore.getState().setReanchorTarget(null);
|
|
123
|
+
}, []);
|
|
112
124
|
const { t } = useLocale();
|
|
113
125
|
|
|
114
|
-
// Show comments errors as toast
|
|
115
126
|
useEffect(() => {
|
|
116
127
|
if (commentsError) {
|
|
117
128
|
toast.error(commentsError);
|
|
118
129
|
}
|
|
119
130
|
}, [commentsError]);
|
|
120
131
|
|
|
121
|
-
const
|
|
132
|
+
const copyComment = useCallback(
|
|
122
133
|
(comment: Comment) => {
|
|
123
|
-
|
|
124
|
-
navigator.clipboard.writeText(raw);
|
|
134
|
+
navigator.clipboard.writeText(formatComment(comment));
|
|
125
135
|
toast.success(t("toast.copied", { text: truncate(comment.comment) }));
|
|
126
136
|
},
|
|
127
137
|
[t],
|
|
128
138
|
);
|
|
129
139
|
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
context,
|
|
139
|
-
fileName,
|
|
140
|
-
comment: comment.comment,
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
navigator.clipboard.writeText(formatted);
|
|
144
|
-
toast.success(
|
|
145
|
-
t("toast.copiedForLLM", { text: truncate(comment.comment) }),
|
|
146
|
-
);
|
|
147
|
-
},
|
|
148
|
-
[documentContent, fileName, t],
|
|
149
|
-
);
|
|
150
|
-
|
|
151
|
-
const copyAllForLLM = useCallback(() => {
|
|
152
|
-
const prompt = generatePrompt(comments, fileName);
|
|
153
|
-
navigator.clipboard.writeText(prompt);
|
|
154
|
-
toast.success(t("toast.copiedAllComments"));
|
|
155
|
-
}, [comments, fileName, t]);
|
|
156
|
-
|
|
157
|
-
const scrollToHighlight = useCallback(
|
|
158
|
-
(commentId: string) => {
|
|
159
|
-
if (documentType === "html") {
|
|
160
|
-
const iframe = window.document.querySelector("iframe");
|
|
161
|
-
iframe?.contentWindow?.postMessage(
|
|
162
|
-
{ type: "scrollToHighlight", commentId },
|
|
163
|
-
"*",
|
|
164
|
-
);
|
|
165
|
-
} else {
|
|
166
|
-
const mark = window.document.querySelector(
|
|
167
|
-
`mark[data-comment-id="${commentId}"]`,
|
|
168
|
-
);
|
|
169
|
-
if (mark) {
|
|
170
|
-
mark.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
},
|
|
174
|
-
[documentType],
|
|
175
|
-
);
|
|
176
|
-
|
|
177
|
-
const commentCount = comments.length;
|
|
140
|
+
const scrollToHighlight = useCallback((commentId: string) => {
|
|
141
|
+
const mark = window.document.querySelector(
|
|
142
|
+
`mark[data-comment-id="${commentId}"]`,
|
|
143
|
+
);
|
|
144
|
+
if (mark) {
|
|
145
|
+
mark.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
146
|
+
}
|
|
147
|
+
}, []);
|
|
178
148
|
|
|
179
|
-
const
|
|
149
|
+
const actions = useMemo<CommentActionsValue>(
|
|
180
150
|
() => ({
|
|
181
|
-
comments,
|
|
182
|
-
commentCount,
|
|
183
151
|
addComment,
|
|
184
152
|
editComment,
|
|
185
153
|
deleteComment,
|
|
186
154
|
deleteAll,
|
|
187
155
|
reanchorComment,
|
|
188
|
-
sortedComments,
|
|
189
|
-
currentIndex,
|
|
190
|
-
hoveredCommentId,
|
|
191
156
|
setHoveredCommentId,
|
|
192
157
|
navigateToComment,
|
|
193
158
|
navigatePrevious,
|
|
194
159
|
navigateNext,
|
|
195
|
-
reanchorTarget,
|
|
196
160
|
startReanchor,
|
|
197
161
|
cancelReanchor,
|
|
198
|
-
|
|
199
|
-
copyCommentForLLM,
|
|
200
|
-
copyAllForLLM,
|
|
162
|
+
copyComment,
|
|
201
163
|
scrollToHighlight,
|
|
202
164
|
}),
|
|
203
165
|
[
|
|
204
|
-
comments,
|
|
205
|
-
commentCount,
|
|
206
166
|
addComment,
|
|
207
167
|
editComment,
|
|
208
168
|
deleteComment,
|
|
209
169
|
deleteAll,
|
|
210
170
|
reanchorComment,
|
|
211
|
-
sortedComments,
|
|
212
|
-
currentIndex,
|
|
213
|
-
hoveredCommentId,
|
|
214
171
|
setHoveredCommentId,
|
|
215
172
|
navigateToComment,
|
|
216
173
|
navigatePrevious,
|
|
217
174
|
navigateNext,
|
|
218
|
-
reanchorTarget,
|
|
219
175
|
startReanchor,
|
|
220
176
|
cancelReanchor,
|
|
221
|
-
|
|
222
|
-
copyCommentForLLM,
|
|
223
|
-
copyAllForLLM,
|
|
177
|
+
copyComment,
|
|
224
178
|
scrollToHighlight,
|
|
225
179
|
],
|
|
226
180
|
);
|
|
227
181
|
|
|
228
|
-
|
|
182
|
+
const data = useMemo<CommentDataValue>(
|
|
183
|
+
() => ({
|
|
184
|
+
comments,
|
|
185
|
+
commentCount: comments.length,
|
|
186
|
+
sortedComments,
|
|
187
|
+
currentIndex,
|
|
188
|
+
reanchorTarget,
|
|
189
|
+
}),
|
|
190
|
+
[comments, sortedComments, currentIndex, reanchorTarget],
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<CommentActionsContext value={actions}>
|
|
195
|
+
<CommentDataContext value={data}>{children}</CommentDataContext>
|
|
196
|
+
</CommentActionsContext>
|
|
197
|
+
);
|
|
229
198
|
}
|
|
@@ -1,6 +1,37 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
use,
|
|
5
|
+
useCallback,
|
|
6
|
+
useMemo,
|
|
7
|
+
useState,
|
|
8
|
+
} from "react";
|
|
9
|
+
import {
|
|
10
|
+
createT,
|
|
11
|
+
type Locale,
|
|
12
|
+
Locales,
|
|
13
|
+
type TranslationKey,
|
|
14
|
+
} from "../lib/i18n";
|
|
15
|
+
|
|
16
|
+
const STORAGE_KEY = "readit:locale";
|
|
17
|
+
|
|
18
|
+
function detectLocale(): Locale {
|
|
19
|
+
const browserLang = navigator.language.slice(0, 2).toLowerCase();
|
|
20
|
+
if (browserLang === "ja") return Locales.JA;
|
|
21
|
+
return Locales.EN;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getStoredLocale(): Locale {
|
|
25
|
+
try {
|
|
26
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
27
|
+
if (stored === Locales.JA || stored === Locales.EN) {
|
|
28
|
+
return stored;
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
// localStorage may be unavailable
|
|
32
|
+
}
|
|
33
|
+
return detectLocale();
|
|
34
|
+
}
|
|
4
35
|
|
|
5
36
|
interface LocaleContextValue {
|
|
6
37
|
locale: Locale;
|
|
@@ -23,7 +54,17 @@ interface LocaleProviderProps {
|
|
|
23
54
|
}
|
|
24
55
|
|
|
25
56
|
export function LocaleProvider({ children }: LocaleProviderProps) {
|
|
26
|
-
const
|
|
57
|
+
const [locale, setLocaleState] = useState<Locale>(getStoredLocale);
|
|
58
|
+
|
|
59
|
+
const setLocale = useCallback((newLocale: Locale) => {
|
|
60
|
+
setLocaleState(newLocale);
|
|
61
|
+
try {
|
|
62
|
+
localStorage.setItem(STORAGE_KEY, newLocale);
|
|
63
|
+
} catch {
|
|
64
|
+
// localStorage may be unavailable
|
|
65
|
+
}
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
27
68
|
const t = useMemo(() => createT(locale), [locale]);
|
|
28
69
|
|
|
29
70
|
const value = useMemo<LocaleContextValue>(
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createContext, type ReactNode, use, useRef } from "react";
|
|
2
|
+
import { Positions } from "../lib/positions";
|
|
3
|
+
|
|
4
|
+
const Ctx = createContext<Positions | null>(null);
|
|
5
|
+
|
|
6
|
+
export function usePositions(): Positions {
|
|
7
|
+
const value = use(Ctx);
|
|
8
|
+
if (!value) throw new Error("usePositions requires PositionsProvider");
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function PositionsProvider({ children }: { children: ReactNode }) {
|
|
13
|
+
const ref = useRef<Positions | null>(null);
|
|
14
|
+
if (!ref.current) ref.current = new Positions();
|
|
15
|
+
return <Ctx value={ref.current}>{children}</Ctx>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
use,
|
|
5
|
+
useCallback,
|
|
6
|
+
useEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { toast } from "sonner";
|
|
11
|
+
import {
|
|
12
|
+
FontFamilies,
|
|
13
|
+
type FontFamily,
|
|
14
|
+
type ThemeMode,
|
|
15
|
+
ThemeModes,
|
|
16
|
+
} from "../schema";
|
|
17
|
+
|
|
18
|
+
const THEME_STORAGE_KEY = "readit:theme";
|
|
19
|
+
const DARK_MQ = "(prefers-color-scheme: dark)";
|
|
20
|
+
|
|
21
|
+
function getStoredTheme(): ThemeMode {
|
|
22
|
+
try {
|
|
23
|
+
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
|
24
|
+
if (
|
|
25
|
+
stored === ThemeModes.LIGHT ||
|
|
26
|
+
stored === ThemeModes.DARK ||
|
|
27
|
+
stored === ThemeModes.SYSTEM
|
|
28
|
+
) {
|
|
29
|
+
return stored;
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
// localStorage may be unavailable
|
|
33
|
+
}
|
|
34
|
+
return ThemeModes.SYSTEM;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function applyTheme(mode: ThemeMode): void {
|
|
38
|
+
const isDark =
|
|
39
|
+
mode === ThemeModes.DARK ||
|
|
40
|
+
(mode === ThemeModes.SYSTEM && window.matchMedia(DARK_MQ).matches);
|
|
41
|
+
|
|
42
|
+
document.documentElement.classList.toggle("dark", isDark);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface SettingsContextValue {
|
|
46
|
+
fontFamily: FontFamily;
|
|
47
|
+
setFontFamily: (font: FontFamily) => Promise<void>;
|
|
48
|
+
themeMode: ThemeMode;
|
|
49
|
+
setThemeMode: (mode: ThemeMode) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const SettingsContext = createContext<SettingsContextValue | null>(null);
|
|
53
|
+
|
|
54
|
+
export function useSettings(): SettingsContextValue {
|
|
55
|
+
const value = use(SettingsContext);
|
|
56
|
+
if (!value) {
|
|
57
|
+
throw new Error("useSettings must be used within a SettingsProvider");
|
|
58
|
+
}
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function SettingsProvider({ children }: { children: ReactNode }) {
|
|
63
|
+
const [fontFamily, setFontFamilyState] = useState<FontFamily>(
|
|
64
|
+
FontFamilies.SERIF,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const fetchSettings = async () => {
|
|
69
|
+
try {
|
|
70
|
+
const response = await fetch("/api/settings");
|
|
71
|
+
if (response.ok) {
|
|
72
|
+
const settings = await response.json();
|
|
73
|
+
setFontFamilyState(settings.fontFamily || FontFamilies.SERIF);
|
|
74
|
+
}
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.error("Failed to fetch settings:", err);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
fetchSettings();
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
const setFontFamily = useCallback(async (font: FontFamily) => {
|
|
84
|
+
setFontFamilyState(font);
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const response = await fetch("/api/settings", {
|
|
88
|
+
method: "PUT",
|
|
89
|
+
headers: { "Content-Type": "application/json" },
|
|
90
|
+
body: JSON.stringify({ fontFamily: font }),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
throw new Error("Failed to save settings");
|
|
95
|
+
}
|
|
96
|
+
} catch (err) {
|
|
97
|
+
console.error("Failed to save font preference:", err);
|
|
98
|
+
toast.error("Failed to save font preference");
|
|
99
|
+
}
|
|
100
|
+
}, []);
|
|
101
|
+
|
|
102
|
+
const [themeMode, setThemeModeState] = useState<ThemeMode>(getStoredTheme);
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
applyTheme(themeMode);
|
|
106
|
+
}, [themeMode]);
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (themeMode !== ThemeModes.SYSTEM) return;
|
|
110
|
+
|
|
111
|
+
const mq = window.matchMedia(DARK_MQ);
|
|
112
|
+
const handler = () => applyTheme(ThemeModes.SYSTEM);
|
|
113
|
+
|
|
114
|
+
mq.addEventListener("change", handler);
|
|
115
|
+
return () => mq.removeEventListener("change", handler);
|
|
116
|
+
}, [themeMode]);
|
|
117
|
+
|
|
118
|
+
const setThemeMode = useCallback((mode: ThemeMode) => {
|
|
119
|
+
setThemeModeState(mode);
|
|
120
|
+
try {
|
|
121
|
+
localStorage.setItem(THEME_STORAGE_KEY, mode);
|
|
122
|
+
} catch {
|
|
123
|
+
// localStorage may be unavailable
|
|
124
|
+
}
|
|
125
|
+
}, []);
|
|
126
|
+
|
|
127
|
+
const value = useMemo<SettingsContextValue>(
|
|
128
|
+
() => ({ fontFamily, setFontFamily, themeMode, setThemeMode }),
|
|
129
|
+
[fontFamily, setFontFamily, themeMode, setThemeMode],
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
return <SettingsContext value={value}>{children}</SettingsContext>;
|
|
133
|
+
}
|
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
import { type RefObject, useEffect } from "react";
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* Close a dropdown/popover when clicking outside or pressing Escape.
|
|
5
|
-
* Only attaches listeners when `active` is true.
|
|
6
|
-
*/
|
|
7
3
|
export function useClickOutside(
|
|
8
4
|
ref: RefObject<HTMLElement | null>,
|
|
9
5
|
onClose: () => void,
|
|
@@ -1,27 +1,19 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
2
|
+
import type { Comment } from "../schema";
|
|
3
|
+
import { uiStore } from "../store";
|
|
4
4
|
|
|
5
5
|
interface UseCommentNavigationResult {
|
|
6
6
|
currentIndex: number;
|
|
7
|
-
hoveredCommentId: string | undefined;
|
|
8
7
|
setHoveredCommentId: (id: string | undefined) => void;
|
|
9
8
|
navigateToComment: (commentId: string) => void;
|
|
10
9
|
navigatePrevious: () => void;
|
|
11
10
|
navigateNext: () => void;
|
|
12
11
|
}
|
|
13
12
|
|
|
14
|
-
/**
|
|
15
|
-
* Manage comment navigation with cycling, keyboard shortcuts, and scroll-to-comment.
|
|
16
|
-
* Handles Alt+↑/↓ keyboard navigation.
|
|
17
|
-
*/
|
|
18
13
|
export function useCommentNavigation(
|
|
19
14
|
sortedComments: Comment[],
|
|
20
15
|
): UseCommentNavigationResult {
|
|
21
16
|
const [currentIndex, setCurrentIndex] = useState(0);
|
|
22
|
-
const hoveredCommentId = useAppStore(
|
|
23
|
-
(s) => s.getActiveDocumentState()?.hoveredCommentId,
|
|
24
|
-
);
|
|
25
17
|
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(
|
|
26
18
|
undefined,
|
|
27
19
|
);
|
|
@@ -30,7 +22,6 @@ export function useCommentNavigation(
|
|
|
30
22
|
const sortedRef = useRef(sortedComments);
|
|
31
23
|
sortedRef.current = sortedComments;
|
|
32
24
|
|
|
33
|
-
// Cleanup hover timeout on unmount
|
|
34
25
|
useEffect(() => {
|
|
35
26
|
return () => clearTimeout(hoverTimeoutRef.current);
|
|
36
27
|
}, []);
|
|
@@ -44,7 +35,6 @@ export function useCommentNavigation(
|
|
|
44
35
|
setCurrentIndex(clampedIndex);
|
|
45
36
|
}
|
|
46
37
|
|
|
47
|
-
// Update DOM data-focused attributes imperatively
|
|
48
38
|
const updateFocusedMarks = useCallback((commentId: string | undefined) => {
|
|
49
39
|
const marks = window.document.querySelectorAll("mark[data-comment-id]");
|
|
50
40
|
for (const mark of marks) {
|
|
@@ -59,13 +49,12 @@ export function useCommentNavigation(
|
|
|
59
49
|
|
|
60
50
|
const setHoveredCommentId = useCallback(
|
|
61
51
|
(id: string | undefined) => {
|
|
62
|
-
|
|
52
|
+
uiStore.setState({ hoveredCommentId: id });
|
|
63
53
|
updateFocusedMarks(id);
|
|
64
54
|
},
|
|
65
55
|
[updateFocusedMarks],
|
|
66
56
|
);
|
|
67
57
|
|
|
68
|
-
// Navigate to a comment by scrolling its highlight into view
|
|
69
58
|
const navigateToComment = useCallback(
|
|
70
59
|
(commentId: string) => {
|
|
71
60
|
const selector = `mark[data-comment-id="${commentId}"]`;
|
|
@@ -80,24 +69,14 @@ export function useCommentNavigation(
|
|
|
80
69
|
);
|
|
81
70
|
};
|
|
82
71
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
scrollAndHighlight(mainHighlight);
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// Try inside iframe (for HTML content)
|
|
91
|
-
const iframe = document.querySelector("iframe");
|
|
92
|
-
const iframeHighlight = iframe?.contentDocument?.querySelector(selector);
|
|
93
|
-
if (iframeHighlight) {
|
|
94
|
-
scrollAndHighlight(iframeHighlight);
|
|
72
|
+
const highlight = document.querySelector(selector);
|
|
73
|
+
if (highlight) {
|
|
74
|
+
scrollAndHighlight(highlight);
|
|
95
75
|
}
|
|
96
76
|
},
|
|
97
77
|
[setHoveredCommentId],
|
|
98
78
|
);
|
|
99
79
|
|
|
100
|
-
// Navigate to previous comment (cycles to last when at first)
|
|
101
80
|
const navigatePrevious = useCallback(() => {
|
|
102
81
|
const sc = sortedRef.current;
|
|
103
82
|
if (sc.length === 0) return;
|
|
@@ -108,7 +87,6 @@ export function useCommentNavigation(
|
|
|
108
87
|
});
|
|
109
88
|
}, [navigateToComment]);
|
|
110
89
|
|
|
111
|
-
// Navigate to next comment (cycles to first when at last)
|
|
112
90
|
const navigateNext = useCallback(() => {
|
|
113
91
|
const sc = sortedRef.current;
|
|
114
92
|
if (sc.length === 0) return;
|
|
@@ -121,7 +99,6 @@ export function useCommentNavigation(
|
|
|
121
99
|
|
|
122
100
|
return {
|
|
123
101
|
currentIndex: clampedIndex,
|
|
124
|
-
hoveredCommentId,
|
|
125
102
|
setHoveredCommentId,
|
|
126
103
|
navigateToComment,
|
|
127
104
|
navigatePrevious,
|