@peaske7/readit 0.1.5 → 0.1.7

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.
Files changed (52) hide show
  1. package/biome.json +1 -1
  2. package/bun.lock +86 -72
  3. package/docs/plans/2026-03-13-client-mode-design.md +86 -0
  4. package/docs/plans/2026-03-13-client-mode-plan.md +605 -0
  5. package/package.json +12 -11
  6. package/src/App.tsx +23 -6
  7. package/src/cli/index.ts +312 -25
  8. package/src/components/ActionsMenu.tsx +12 -10
  9. package/src/components/DocumentViewer/CodeBlock.tsx +131 -54
  10. package/src/components/DocumentViewer/DocumentViewer.tsx +6 -6
  11. package/src/components/DocumentViewer/InlineCode.tsx +60 -0
  12. package/src/components/FloatingTOC.tsx +4 -2
  13. package/src/components/Header.tsx +3 -1
  14. package/src/components/InlineEditor.tsx +4 -2
  15. package/src/components/MarginNote.tsx +17 -8
  16. package/src/components/RawModal.tsx +9 -7
  17. package/src/components/ReanchorConfirm.tsx +6 -3
  18. package/src/components/SettingsModal.tsx +112 -23
  19. package/src/components/ShortcutCapture.tsx +4 -1
  20. package/src/components/ShortcutList.tsx +50 -9
  21. package/src/components/comments/CommentBadge.tsx +7 -1
  22. package/src/components/comments/CommentInput.tsx +13 -18
  23. package/src/components/comments/CommentListItem.tsx +15 -5
  24. package/src/components/comments/CommentManager.tsx +14 -7
  25. package/src/components/comments/CommentNav.tsx +8 -3
  26. package/src/contexts/CommentContext.tsx +16 -9
  27. package/src/contexts/LayoutContext.tsx +17 -5
  28. package/src/contexts/LocaleContext.tsx +35 -0
  29. package/src/hooks/useClipboard.ts +11 -8
  30. package/src/hooks/useDocument.ts +35 -10
  31. package/src/hooks/useEditorScheme.ts +51 -0
  32. package/src/hooks/useFontPreference.ts +5 -22
  33. package/src/hooks/useKeybindings.ts +6 -18
  34. package/src/hooks/useLocalePreference.ts +42 -0
  35. package/src/index.css +87 -26
  36. package/src/lib/editor-links.ts +59 -0
  37. package/src/lib/highlight/dom.ts +126 -54
  38. package/src/lib/highlight/highlighter.ts +10 -10
  39. package/src/lib/i18n/completeness.test.ts +51 -0
  40. package/src/lib/i18n/en.ts +139 -0
  41. package/src/lib/i18n/index.ts +3 -0
  42. package/src/lib/i18n/ja.ts +141 -0
  43. package/src/lib/i18n/translations.test.ts +39 -0
  44. package/src/lib/i18n/translations.ts +27 -0
  45. package/src/lib/i18n/types.ts +145 -0
  46. package/src/lib/shortcut-registry.ts +1 -1
  47. package/src/lib/utils.ts +11 -0
  48. package/src/main.tsx +4 -1
  49. package/src/server/index.ts +263 -103
  50. package/src/store/index.test.ts +22 -0
  51. package/src/store/index.ts +24 -4
  52. package/src/types/index.ts +12 -0
@@ -15,6 +15,7 @@ import { generatePrompt } from "../lib/export";
15
15
  import { truncate } from "../lib/utils";
16
16
  import { useAppStore } from "../store";
17
17
  import type { Comment, DocumentType } from "../types";
18
+ import { useLocale } from "./LocaleContext";
18
19
 
19
20
  interface CommentContextValue {
20
21
  // From useComments
@@ -108,6 +109,7 @@ export function CommentProvider({
108
109
  } = useCommentNavigation(sortedComments);
109
110
 
110
111
  const { reanchorTarget, startReanchor, cancelReanchor } = useReanchorMode();
112
+ const { t } = useLocale();
111
113
 
112
114
  // Show comments errors as toast
113
115
  useEffect(() => {
@@ -116,11 +118,14 @@ export function CommentProvider({
116
118
  }
117
119
  }, [commentsError]);
118
120
 
119
- const copyCommentRaw = useCallback((comment: Comment) => {
120
- const raw = `${comment.selectedText}\n\n${comment.comment}`;
121
- navigator.clipboard.writeText(raw);
122
- toast.success(`Copied: "${truncate(comment.comment)}"`);
123
- }, []);
121
+ const copyCommentRaw = useCallback(
122
+ (comment: Comment) => {
123
+ const raw = `${comment.selectedText}\n\n${comment.comment}`;
124
+ navigator.clipboard.writeText(raw);
125
+ toast.success(t("toast.copied", { text: truncate(comment.comment) }));
126
+ },
127
+ [t],
128
+ );
124
129
 
125
130
  const copyCommentForLLM = useCallback(
126
131
  (comment: Comment) => {
@@ -136,16 +141,18 @@ export function CommentProvider({
136
141
  });
137
142
 
138
143
  navigator.clipboard.writeText(formatted);
139
- toast.success(`Copied for LLM: "${truncate(comment.comment)}"`);
144
+ toast.success(
145
+ t("toast.copiedForLLM", { text: truncate(comment.comment) }),
146
+ );
140
147
  },
141
- [documentContent, fileName],
148
+ [documentContent, fileName, t],
142
149
  );
143
150
 
144
151
  const copyAllForLLM = useCallback(() => {
145
152
  const prompt = generatePrompt(comments, fileName);
146
153
  navigator.clipboard.writeText(prompt);
147
- toast.success("Copied all comments");
148
- }, [comments, fileName]);
154
+ toast.success(t("toast.copiedAllComments"));
155
+ }, [comments, fileName, t]);
149
156
 
150
157
  const scrollToHighlight = useCallback(
151
158
  (commentId: string) => {
@@ -1,16 +1,24 @@
1
1
  import { createContext, type ReactNode, use, useMemo } from "react";
2
+ import { useEditorScheme } from "../hooks/useEditorScheme";
2
3
  import { useFontPreference } from "../hooks/useFontPreference";
3
4
  import { useKeybindings } from "../hooks/useKeybindings";
4
5
  import { useLayoutMode } from "../hooks/useLayoutMode";
5
6
  import { useThemePreference } from "../hooks/useThemePreference";
6
7
  import type { ShortcutDefinition } from "../lib/shortcut-registry";
7
- import type { FontFamily, ShortcutBinding, ThemeMode } from "../types";
8
+ import type {
9
+ EditorScheme,
10
+ FontFamily,
11
+ ShortcutBinding,
12
+ ThemeMode,
13
+ } from "../types";
8
14
 
9
15
  interface LayoutContextValue {
10
16
  isFullscreen: boolean;
11
17
  toggleLayoutMode: () => void;
12
18
  fontFamily: FontFamily;
13
19
  setFontFamily: (font: FontFamily) => Promise<void>;
20
+ editorScheme: EditorScheme;
21
+ setEditorScheme: (scheme: EditorScheme) => Promise<void>;
14
22
  themeMode: ThemeMode;
15
23
  setThemeMode: (mode: ThemeMode) => void;
16
24
  shortcuts: ShortcutDefinition[];
@@ -30,20 +38,20 @@ export function useLayoutContext(): LayoutContextValue {
30
38
  }
31
39
 
32
40
  interface LayoutProviderProps {
33
- filePath: string;
34
41
  children: ReactNode;
35
42
  }
36
43
 
37
- export function LayoutProvider({ filePath, children }: LayoutProviderProps) {
44
+ export function LayoutProvider({ children }: LayoutProviderProps) {
38
45
  const { isFullscreen, toggleLayoutMode } = useLayoutMode();
39
- const { fontFamily, setFontFamily } = useFontPreference(filePath);
46
+ const { fontFamily, setFontFamily } = useFontPreference();
47
+ const { editorScheme, setEditorScheme } = useEditorScheme();
40
48
  const { themeMode, setThemeMode } = useThemePreference();
41
49
  const {
42
50
  shortcuts,
43
51
  updateBinding,
44
52
  toggleEnabled: toggleShortcutEnabled,
45
53
  resetToDefaults: resetShortcutsToDefaults,
46
- } = useKeybindings(filePath);
54
+ } = useKeybindings();
47
55
 
48
56
  const value = useMemo<LayoutContextValue>(
49
57
  () => ({
@@ -51,6 +59,8 @@ export function LayoutProvider({ filePath, children }: LayoutProviderProps) {
51
59
  toggleLayoutMode,
52
60
  fontFamily,
53
61
  setFontFamily,
62
+ editorScheme,
63
+ setEditorScheme,
54
64
  themeMode,
55
65
  setThemeMode,
56
66
  shortcuts,
@@ -63,6 +73,8 @@ export function LayoutProvider({ filePath, children }: LayoutProviderProps) {
63
73
  toggleLayoutMode,
64
74
  fontFamily,
65
75
  setFontFamily,
76
+ editorScheme,
77
+ setEditorScheme,
66
78
  themeMode,
67
79
  setThemeMode,
68
80
  shortcuts,
@@ -0,0 +1,35 @@
1
+ import { createContext, type ReactNode, use, useMemo } from "react";
2
+ import { useLocalePreference } from "../hooks/useLocalePreference";
3
+ import { createT, type Locale, type TranslationKey } from "../lib/i18n";
4
+
5
+ interface LocaleContextValue {
6
+ locale: Locale;
7
+ setLocale: (locale: Locale) => void;
8
+ t: (key: TranslationKey, params?: Record<string, string | number>) => string;
9
+ }
10
+
11
+ const LocaleContext = createContext<LocaleContextValue | null>(null);
12
+
13
+ export function useLocale(): LocaleContextValue {
14
+ const value = use(LocaleContext);
15
+ if (!value) {
16
+ throw new Error("useLocale must be used within a LocaleProvider");
17
+ }
18
+ return value;
19
+ }
20
+
21
+ interface LocaleProviderProps {
22
+ children: ReactNode;
23
+ }
24
+
25
+ export function LocaleProvider({ children }: LocaleProviderProps) {
26
+ const { locale, setLocale } = useLocalePreference();
27
+ const t = useMemo(() => createT(locale), [locale]);
28
+
29
+ const value = useMemo<LocaleContextValue>(
30
+ () => ({ locale, setLocale, t }),
31
+ [locale, setLocale, t],
32
+ );
33
+
34
+ return <LocaleContext value={value}>{children}</LocaleContext>;
35
+ }
@@ -6,6 +6,7 @@ import {
6
6
  generatePrompt,
7
7
  generateRawText,
8
8
  } from "../lib/export";
9
+ import type { TranslationKey } from "../lib/i18n";
9
10
  import { truncate } from "../lib/utils";
10
11
  import type { Comment, Document, Selection } from "../types";
11
12
 
@@ -14,6 +15,7 @@ interface UseClipboardParams {
14
15
  document: Document | undefined;
15
16
  selection: Selection | undefined;
16
17
  clearSelection: () => void;
18
+ t: (key: TranslationKey, params?: Record<string, string | number>) => string;
17
19
  }
18
20
 
19
21
  export function useClipboard({
@@ -21,21 +23,22 @@ export function useClipboard({
21
23
  document,
22
24
  selection,
23
25
  clearSelection,
26
+ t,
24
27
  }: UseClipboardParams) {
25
28
  // Export handlers
26
29
  const copyAll = useCallback(() => {
27
30
  if (!document) return;
28
31
  const prompt = generatePrompt(comments, document.fileName);
29
32
  navigator.clipboard.writeText(prompt);
30
- toast.success("Copied all comments");
31
- }, [comments, document]);
33
+ toast.success(t("toast.copiedAllComments"));
34
+ }, [comments, document, t]);
32
35
 
33
36
  const copyAllRaw = useCallback(() => {
34
37
  if (!document) return;
35
38
  const raw = generateRawText(comments);
36
39
  navigator.clipboard.writeText(raw);
37
- toast.success("Copied all comments as raw text");
38
- }, [comments, document]);
40
+ toast.success(t("toast.copiedAllRaw"));
41
+ }, [comments, document, t]);
39
42
 
40
43
  const exportJson = useCallback(() => {
41
44
  if (!document) return;
@@ -47,9 +50,9 @@ export function useClipboard({
47
50
  if (!selection) return;
48
51
 
49
52
  navigator.clipboard.writeText(selection.text);
50
- toast.success(`Copied: "${truncate(selection.text)}"`);
53
+ toast.success(t("toast.copied", { text: truncate(selection.text) }));
51
54
  clearSelection();
52
- }, [selection, clearSelection]);
55
+ }, [selection, clearSelection, t]);
53
56
 
54
57
  const copySelectionForLLM = useCallback(() => {
55
58
  if (!selection || !document) return;
@@ -65,9 +68,9 @@ export function useClipboard({
65
68
  });
66
69
 
67
70
  navigator.clipboard.writeText(formatted);
68
- toast.success(`Copied for LLM: "${truncate(selection.text)}"`);
71
+ toast.success(t("toast.copiedForLLM", { text: truncate(selection.text) }));
69
72
  clearSelection();
70
- }, [selection, document, clearSelection]);
73
+ }, [selection, document, clearSelection, t]);
71
74
 
72
75
  return {
73
76
  copyAll,
@@ -10,6 +10,12 @@ interface UseDocumentResult {
10
10
  reload: () => Promise<void>;
11
11
  }
12
12
 
13
+ interface DocListItem {
14
+ path: string;
15
+ fileName: string;
16
+ type: Document["type"];
17
+ }
18
+
13
19
  /**
14
20
  * Manage multi-document loading, lazy content fetching, and live reloading.
15
21
  *
@@ -39,15 +45,21 @@ export function useDocument(): UseDocumentResult {
39
45
  const data = await res.json();
40
46
 
41
47
  const clean = data.clean || false;
42
- for (const file of data.files) {
43
- appStore.getState().openDocument({
44
- content: "", // Content loaded lazily on tab activation
45
- type: file.type,
46
- filePath: file.path,
47
- fileName: file.fileName,
48
- clean,
49
- });
48
+ if (data.workingDirectory) {
49
+ appStore.getState().setWorkingDirectory(data.workingDirectory);
50
50
  }
51
+ data.files.forEach((file: DocListItem, index: number) => {
52
+ appStore.getState().openDocument(
53
+ {
54
+ content: "", // Content loaded lazily on tab activation
55
+ type: file.type,
56
+ filePath: file.path,
57
+ fileName: file.fileName,
58
+ clean,
59
+ },
60
+ { active: index === 0 },
61
+ );
62
+ });
51
63
  } catch (err) {
52
64
  setError(
53
65
  err instanceof Error ? err.message : "Failed to load documents",
@@ -84,13 +96,26 @@ export function useDocument(): UseDocumentResult {
84
96
  loadContent();
85
97
  }, [activeDocumentPath]);
86
98
 
87
- // SSE: listen for file updates, reload content for already-loaded documents
99
+ // SSE: register new documents without stealing focus; reload loaded docs on updates
88
100
  useEffect(() => {
89
101
  const eventSource = new EventSource("/api/document/stream");
90
102
  eventSource.onmessage = async (e) => {
91
103
  try {
92
104
  const data = JSON.parse(e.data);
93
- if (data.type === "update" && data.path) {
105
+ if (data.type === "document-added" && data.path) {
106
+ appStore.getState().openDocument(
107
+ {
108
+ content: "", // Lazy-loaded when tab activated
109
+ type: data.fileType,
110
+ filePath: data.path,
111
+ fileName: data.fileName,
112
+ clean: false,
113
+ },
114
+ { active: false },
115
+ );
116
+ return;
117
+ }
118
+ if (data.type === "document-updated" && data.path) {
94
119
  // Only reload if content was previously loaded
95
120
  const state = appStore.getState().documents.get(data.path);
96
121
  if (!state || !state.document.content) return;
@@ -0,0 +1,51 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import { toast } from "sonner";
3
+ import { type EditorScheme, EditorSchemes } from "../types";
4
+
5
+ interface UseEditorSchemeResult {
6
+ editorScheme: EditorScheme;
7
+ setEditorScheme: (scheme: EditorScheme) => Promise<void>;
8
+ }
9
+
10
+ export function useEditorScheme(): UseEditorSchemeResult {
11
+ const [editorScheme, setEditorSchemeState] = useState<EditorScheme>(
12
+ EditorSchemes.NONE,
13
+ );
14
+
15
+ useEffect(() => {
16
+ const fetchSettings = async () => {
17
+ try {
18
+ const response = await fetch("/api/settings");
19
+ if (response.ok) {
20
+ const settings = await response.json();
21
+ setEditorSchemeState(settings.editorScheme || EditorSchemes.NONE);
22
+ }
23
+ } catch (err) {
24
+ console.error("Failed to fetch settings:", err);
25
+ }
26
+ };
27
+
28
+ fetchSettings();
29
+ }, []);
30
+
31
+ const setEditorScheme = useCallback(async (scheme: EditorScheme) => {
32
+ setEditorSchemeState(scheme);
33
+
34
+ try {
35
+ const response = await fetch("/api/settings", {
36
+ method: "PUT",
37
+ headers: { "Content-Type": "application/json" },
38
+ body: JSON.stringify({ editorScheme: scheme }),
39
+ });
40
+
41
+ if (!response.ok) {
42
+ throw new Error("Failed to save settings");
43
+ }
44
+ } catch (err) {
45
+ console.error("Failed to save editor scheme:", err);
46
+ toast.error("Failed to save editor scheme");
47
+ }
48
+ }, []);
49
+
50
+ return { editorScheme, setEditorScheme };
51
+ }
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useState } from "react";
2
2
  import { toast } from "sonner";
3
3
  import { FontFamilies, type FontFamily } from "../types";
4
4
 
@@ -8,30 +8,16 @@ interface UseFontPreferenceResult {
8
8
  isLoading: boolean;
9
9
  }
10
10
 
11
- export function useFontPreference(
12
- filePath: string | null,
13
- ): UseFontPreferenceResult {
11
+ export function useFontPreference(): UseFontPreferenceResult {
14
12
  const [fontFamily, setFontFamilyState] = useState<FontFamily>(
15
13
  FontFamilies.SERIF,
16
14
  );
17
15
  const [isLoading, setIsLoading] = useState(true);
18
16
 
19
- const filePathRef = useRef(filePath);
20
- filePathRef.current = filePath;
21
-
22
- // Fetch settings when filePath changes
23
17
  useEffect(() => {
24
- if (!filePath) {
25
- setIsLoading(false);
26
- return;
27
- }
28
-
29
- setIsLoading(true);
30
-
31
18
  const fetchSettings = async () => {
32
19
  try {
33
- const query = `?path=${encodeURIComponent(filePath)}`;
34
- const response = await fetch(`/api/settings${query}`);
20
+ const response = await fetch("/api/settings");
35
21
  if (response.ok) {
36
22
  const settings = await response.json();
37
23
  setFontFamilyState(settings.fontFamily || FontFamilies.SERIF);
@@ -44,16 +30,13 @@ export function useFontPreference(
44
30
  };
45
31
 
46
32
  fetchSettings();
47
- }, [filePath]);
33
+ }, []);
48
34
 
49
35
  const setFontFamily = useCallback(async (font: FontFamily) => {
50
- // Optimistic update
51
36
  setFontFamilyState(font);
52
37
 
53
38
  try {
54
- const fp = filePathRef.current;
55
- const query = fp ? `?path=${encodeURIComponent(fp)}` : "";
56
- const response = await fetch(`/api/settings${query}`, {
39
+ const response = await fetch("/api/settings", {
57
40
  method: "PUT",
58
41
  headers: { "Content-Type": "application/json" },
59
42
  body: JSON.stringify({ fontFamily: font }),
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useState } from "react";
2
2
  import { toast } from "sonner";
3
3
  import {
4
4
  resolveShortcuts,
@@ -14,24 +14,14 @@ interface UseKeybindingsResult {
14
14
  isLoading: boolean;
15
15
  }
16
16
 
17
- export function useKeybindings(filePath: string | null): UseKeybindingsResult {
17
+ export function useKeybindings(): UseKeybindingsResult {
18
18
  const [overrides, setOverrides] = useState<KeybindingOverride[]>([]);
19
19
  const [isLoading, setIsLoading] = useState(true);
20
20
 
21
- const filePathRef = useRef(filePath);
22
- filePathRef.current = filePath;
23
-
24
- // Fetch keybindings from settings on mount
25
21
  useEffect(() => {
26
- if (!filePath) {
27
- setIsLoading(false);
28
- return;
29
- }
30
-
31
22
  const fetchKeybindings = async () => {
32
23
  try {
33
- const query = `?path=${encodeURIComponent(filePath)}`;
34
- const response = await fetch(`/api/settings${query}`);
24
+ const response = await fetch("/api/settings");
35
25
  if (response.ok) {
36
26
  const settings = await response.json();
37
27
  setOverrides(settings.keybindings ?? []);
@@ -44,20 +34,18 @@ export function useKeybindings(filePath: string | null): UseKeybindingsResult {
44
34
  };
45
35
 
46
36
  fetchKeybindings();
47
- }, [filePath]);
37
+ }, []);
48
38
 
49
39
  const persistOverrides = useCallback(
50
40
  async (newOverrides: KeybindingOverride[]) => {
51
41
  try {
52
- const fp = filePathRef.current;
53
- const query = fp ? `?path=${encodeURIComponent(fp)}` : "";
54
- const response = await fetch(`/api/settings${query}`);
42
+ const response = await fetch("/api/settings");
55
43
  if (!response.ok) return;
56
44
 
57
45
  const currentSettings = await response.json();
58
46
  const updated = { ...currentSettings, keybindings: newOverrides };
59
47
 
60
- const putResponse = await fetch(`/api/settings${query}`, {
48
+ const putResponse = await fetch("/api/settings", {
61
49
  method: "PUT",
62
50
  headers: { "Content-Type": "application/json" },
63
51
  body: JSON.stringify(updated),
@@ -0,0 +1,42 @@
1
+ import { useCallback, useState } from "react";
2
+ import { type Locale, Locales } from "../lib/i18n";
3
+
4
+ const STORAGE_KEY = "readit:locale";
5
+
6
+ function detectLocale(): Locale {
7
+ const browserLang = navigator.language.slice(0, 2).toLowerCase();
8
+ if (browserLang === "ja") return Locales.JA;
9
+ return Locales.EN;
10
+ }
11
+
12
+ function getStoredLocale(): Locale {
13
+ try {
14
+ const stored = localStorage.getItem(STORAGE_KEY);
15
+ if (stored === Locales.JA || stored === Locales.EN) {
16
+ return stored;
17
+ }
18
+ } catch {
19
+ // localStorage may be unavailable
20
+ }
21
+ return detectLocale();
22
+ }
23
+
24
+ interface UseLocalePreferenceResult {
25
+ locale: Locale;
26
+ setLocale: (locale: Locale) => void;
27
+ }
28
+
29
+ export function useLocalePreference(): UseLocalePreferenceResult {
30
+ const [locale, setLocaleState] = useState<Locale>(getStoredLocale);
31
+
32
+ const setLocale = useCallback((newLocale: Locale) => {
33
+ setLocaleState(newLocale);
34
+ try {
35
+ localStorage.setItem(STORAGE_KEY, newLocale);
36
+ } catch {
37
+ // localStorage may be unavailable
38
+ }
39
+ }, []);
40
+
41
+ return { locale, setLocale };
42
+ }