@peaske7/readit 0.1.8 → 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 +124 -172
- package/src/{cli/index.ts → cli.ts} +37 -53
- package/src/components/ActionsMenu.tsx +6 -27
- package/src/components/DocumentViewer/DocumentViewer.tsx +77 -106
- 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} +74 -74
- 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
|
@@ -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,
|
package/src/hooks/useComments.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import { AnchorConfidences, type Comment } from "../schema";
|
|
2
3
|
import { appStore, useAppStore } from "../store";
|
|
3
|
-
import { AnchorConfidences, type Comment } from "../types";
|
|
4
4
|
|
|
5
5
|
interface UseCommentsOptions {
|
|
6
6
|
clean?: boolean;
|
|
@@ -26,17 +26,12 @@ interface UseCommentsResult {
|
|
|
26
26
|
) => void;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
/**
|
|
30
|
-
* Hook for managing comments with optimistic updates.
|
|
31
|
-
* State lives in the Zustand store; this hook coordinates API mutations.
|
|
32
|
-
*/
|
|
33
29
|
export function useComments(
|
|
34
30
|
filePath: string | null,
|
|
35
31
|
options: UseCommentsOptions = {},
|
|
36
32
|
): UseCommentsResult {
|
|
37
33
|
const { clean = false } = options;
|
|
38
34
|
|
|
39
|
-
// Read comments and error from the store
|
|
40
35
|
const comments = useAppStore(
|
|
41
36
|
(s) => s.documents.get(filePath ?? "")?.comments ?? [],
|
|
42
37
|
);
|
|
@@ -44,16 +39,12 @@ export function useComments(
|
|
|
44
39
|
(s) => s.documents.get(filePath ?? "")?.commentsError ?? undefined,
|
|
45
40
|
);
|
|
46
41
|
|
|
47
|
-
// Track pending operations for rollback on error
|
|
48
42
|
const pendingOperations = useRef<Map<string, Comment[]>>(new Map());
|
|
49
43
|
|
|
50
|
-
// Capture filePath at call time
|
|
44
|
+
// Capture filePath at call time so callbacks stay stable across renders
|
|
51
45
|
const filePathRef = useRef(filePath);
|
|
52
46
|
filePathRef.current = filePath;
|
|
53
47
|
|
|
54
|
-
/**
|
|
55
|
-
* Execute an optimistic mutation with automatic rollback on error.
|
|
56
|
-
*/
|
|
57
48
|
const executeMutation = useCallback(
|
|
58
49
|
async <T>({
|
|
59
50
|
operationId,
|
|
@@ -71,19 +62,16 @@ export function useComments(
|
|
|
71
62
|
const fp = filePathRef.current;
|
|
72
63
|
if (!fp) return;
|
|
73
64
|
|
|
74
|
-
// Read current comments from store for rollback
|
|
75
65
|
const currentDocState = appStore.getState().documents.get(fp);
|
|
76
66
|
const previousComments = [...(currentDocState?.comments ?? [])];
|
|
77
67
|
pendingOperations.current.set(operationId, previousComments);
|
|
78
68
|
|
|
79
|
-
// Apply optimistic update
|
|
80
69
|
appStore.getState().setComments(optimisticUpdate(previousComments), fp);
|
|
81
70
|
appStore.getState().setCommentsError(null, fp);
|
|
82
71
|
|
|
83
72
|
try {
|
|
84
73
|
const result = await apiCall();
|
|
85
74
|
|
|
86
|
-
// Apply server response transformation if provided
|
|
87
75
|
if (onSuccess) {
|
|
88
76
|
const current = appStore.getState().documents.get(fp)?.comments ?? [];
|
|
89
77
|
appStore.getState().setComments(onSuccess(result, current), fp);
|
|
@@ -97,7 +85,6 @@ export function useComments(
|
|
|
97
85
|
fp,
|
|
98
86
|
);
|
|
99
87
|
|
|
100
|
-
// Rollback on error
|
|
101
88
|
const rollback = pendingOperations.current.get(operationId);
|
|
102
89
|
if (rollback) {
|
|
103
90
|
appStore.getState().setComments(rollback, fp);
|
|
@@ -109,23 +96,24 @@ export function useComments(
|
|
|
109
96
|
[],
|
|
110
97
|
);
|
|
111
98
|
|
|
112
|
-
// Build path-scoped API URL
|
|
113
99
|
const pathQuery = useCallback((base: string) => {
|
|
114
100
|
const fp = filePathRef.current;
|
|
115
101
|
if (!fp) return base;
|
|
116
102
|
return `${base}?path=${encodeURIComponent(fp)}`;
|
|
117
103
|
}, []);
|
|
118
104
|
|
|
119
|
-
// Load comments from API
|
|
120
105
|
useEffect(() => {
|
|
121
106
|
if (!filePath) return;
|
|
122
107
|
|
|
108
|
+
// Skip fetch if comments were already pre-fetched by useDocument (parallel loading)
|
|
109
|
+
const existing = appStore.getState().documents.get(filePath);
|
|
110
|
+
if (!clean && existing && existing.comments.length > 0) return;
|
|
111
|
+
|
|
123
112
|
const loadComments = async () => {
|
|
124
113
|
appStore.getState().setCommentsError(null, filePath);
|
|
125
114
|
const query = `?path=${encodeURIComponent(filePath)}`;
|
|
126
115
|
|
|
127
116
|
try {
|
|
128
|
-
// If clean flag is set, clear comments first
|
|
129
117
|
if (clean) {
|
|
130
118
|
await fetch(`/api/comments${query}`, { method: "DELETE" });
|
|
131
119
|
appStore.getState().setComments([], filePath);
|
package/src/hooks/useDocument.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useState } from "react";
|
|
2
2
|
import { toast } from "sonner";
|
|
3
|
+
import type { Document } from "../schema";
|
|
3
4
|
import { appStore, useAppStore } from "../store";
|
|
4
|
-
import type { Document } from "../types";
|
|
5
5
|
|
|
6
6
|
interface UseDocumentResult {
|
|
7
7
|
document: Document | null;
|
|
@@ -13,30 +13,20 @@ interface UseDocumentResult {
|
|
|
13
13
|
interface DocListItem {
|
|
14
14
|
path: string;
|
|
15
15
|
fileName: string;
|
|
16
|
-
type: Document["type"];
|
|
17
16
|
}
|
|
18
17
|
|
|
19
|
-
/**
|
|
20
|
-
* Manage multi-document loading, lazy content fetching, and live reloading.
|
|
21
|
-
*
|
|
22
|
-
* On mount: fetches the document list from `/api/documents` and opens all
|
|
23
|
-
* files in the store. Content is loaded lazily when a tab becomes active.
|
|
24
|
-
* SSE events trigger content updates for already-loaded documents.
|
|
25
|
-
*/
|
|
26
18
|
export function useDocument(): UseDocumentResult {
|
|
27
19
|
const [error, setError] = useState<string | null>(null);
|
|
28
20
|
const [isInitialized, setIsInitialized] = useState(false);
|
|
29
21
|
|
|
30
22
|
const activeDocumentPath = useAppStore((s) => s.activeDocumentPath);
|
|
31
23
|
|
|
32
|
-
// Active document — null until content is loaded
|
|
33
24
|
const document = useAppStore((s) => {
|
|
34
25
|
const ds = s.getActiveDocumentState();
|
|
35
|
-
if (!ds
|
|
26
|
+
if (!ds?.document.content) return null;
|
|
36
27
|
return ds.document;
|
|
37
28
|
});
|
|
38
29
|
|
|
39
|
-
// Fetch document list on mount, populate store
|
|
40
30
|
useEffect(() => {
|
|
41
31
|
async function init() {
|
|
42
32
|
try {
|
|
@@ -51,8 +41,7 @@ export function useDocument(): UseDocumentResult {
|
|
|
51
41
|
data.files.forEach((file: DocListItem, index: number) => {
|
|
52
42
|
appStore.getState().openDocument(
|
|
53
43
|
{
|
|
54
|
-
content: "",
|
|
55
|
-
type: file.type,
|
|
44
|
+
content: "",
|
|
56
45
|
filePath: file.path,
|
|
57
46
|
fileName: file.fileName,
|
|
58
47
|
clean,
|
|
@@ -71,32 +60,46 @@ export function useDocument(): UseDocumentResult {
|
|
|
71
60
|
init();
|
|
72
61
|
}, []);
|
|
73
62
|
|
|
74
|
-
// Load content when active document changes and has no content yet
|
|
75
63
|
useEffect(() => {
|
|
76
64
|
if (!activeDocumentPath) return;
|
|
77
65
|
const state = appStore.getState().documents.get(activeDocumentPath);
|
|
78
66
|
if (!state || state.document.content) return;
|
|
79
67
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
68
|
+
const path = activeDocumentPath;
|
|
69
|
+
const query = `?path=${encodeURIComponent(path)}`;
|
|
70
|
+
const isClean = state.document.clean;
|
|
71
|
+
|
|
72
|
+
// Fetch document content and comments in parallel so highlights
|
|
73
|
+
// can apply immediately when CommentProvider mounts.
|
|
74
|
+
const docFetch = fetch(`/api/document${query}`).then((r) => {
|
|
75
|
+
if (!r.ok) throw new Error(`Server error: ${r.status}`);
|
|
76
|
+
return r.json();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const commentsFetch = isClean
|
|
80
|
+
? fetch(`/api/comments${query}`, { method: "DELETE" }).then(
|
|
81
|
+
() => [] as unknown[],
|
|
82
|
+
)
|
|
83
|
+
: fetch(`/api/comments${query}`)
|
|
84
|
+
.then((r) => (r.ok ? r.json() : { comments: [] }))
|
|
85
|
+
.then((d) => d.comments || []);
|
|
86
|
+
|
|
87
|
+
Promise.all([docFetch, commentsFetch]).then(
|
|
88
|
+
([docData, comments]) => {
|
|
89
|
+
// Set comments BEFORE content: content triggers CommentProvider mount,
|
|
90
|
+
// so comments must already be in the store to avoid a wasted empty render.
|
|
91
|
+
appStore.getState().setComments(comments, path);
|
|
92
|
+
appStore.getState().updateDocumentContent(docData.content, path);
|
|
93
|
+
},
|
|
94
|
+
(err) => {
|
|
91
95
|
setError(
|
|
92
96
|
err instanceof Error ? err.message : "Failed to load document",
|
|
93
97
|
);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
loadContent();
|
|
98
|
+
},
|
|
99
|
+
);
|
|
97
100
|
}, [activeDocumentPath]);
|
|
98
101
|
|
|
99
|
-
// SSE: register new documents without stealing focus; reload loaded docs on updates
|
|
102
|
+
// SSE: register new documents without stealing focus; reload already-loaded docs on updates
|
|
100
103
|
useEffect(() => {
|
|
101
104
|
const eventSource = new EventSource("/api/document/stream");
|
|
102
105
|
eventSource.onmessage = async (e) => {
|
|
@@ -105,8 +108,7 @@ export function useDocument(): UseDocumentResult {
|
|
|
105
108
|
if (data.type === "document-added" && data.path) {
|
|
106
109
|
appStore.getState().openDocument(
|
|
107
110
|
{
|
|
108
|
-
content: "",
|
|
109
|
-
type: data.fileType,
|
|
111
|
+
content: "",
|
|
110
112
|
filePath: data.path,
|
|
111
113
|
fileName: data.fileName,
|
|
112
114
|
clean: false,
|
|
@@ -116,9 +118,8 @@ export function useDocument(): UseDocumentResult {
|
|
|
116
118
|
return;
|
|
117
119
|
}
|
|
118
120
|
if (data.type === "document-updated" && data.path) {
|
|
119
|
-
// Only reload if content was previously loaded
|
|
120
121
|
const state = appStore.getState().documents.get(data.path);
|
|
121
|
-
if (!state
|
|
122
|
+
if (!state?.document.content) return;
|
|
122
123
|
|
|
123
124
|
const res = await fetch(
|
|
124
125
|
`/api/document?path=${encodeURIComponent(data.path)}`,
|
|
@@ -2,13 +2,13 @@ import { renderHook } from "@testing-library/react";
|
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
3
|
import { useHeadings } from "./useHeadings";
|
|
4
4
|
|
|
5
|
-
describe("useHeadings
|
|
5
|
+
describe("useHeadings", () => {
|
|
6
6
|
it("extracts basic headings", () => {
|
|
7
7
|
const content = `# Heading 1
|
|
8
8
|
## Heading 2
|
|
9
9
|
### Heading 3`;
|
|
10
10
|
|
|
11
|
-
const { result } = renderHook(() => useHeadings(content
|
|
11
|
+
const { result } = renderHook(() => useHeadings(content));
|
|
12
12
|
|
|
13
13
|
expect(result.current).toEqual([
|
|
14
14
|
{ id: "heading-1", text: "Heading 1", level: 1 },
|
|
@@ -22,7 +22,7 @@ describe("useHeadings - markdown", () => {
|
|
|
22
22
|
## Section
|
|
23
23
|
## Section`;
|
|
24
24
|
|
|
25
|
-
const { result } = renderHook(() => useHeadings(content
|
|
25
|
+
const { result } = renderHook(() => useHeadings(content));
|
|
26
26
|
|
|
27
27
|
expect(result.current).toEqual([
|
|
28
28
|
{ id: "section", text: "Section", level: 2 },
|
|
@@ -41,7 +41,7 @@ echo "hello"
|
|
|
41
41
|
|
|
42
42
|
## Another Real Heading`;
|
|
43
43
|
|
|
44
|
-
const { result } = renderHook(() => useHeadings(content
|
|
44
|
+
const { result } = renderHook(() => useHeadings(content));
|
|
45
45
|
|
|
46
46
|
expect(result.current).toEqual([
|
|
47
47
|
{ id: "real-heading", text: "Real Heading", level: 1 },
|
|
@@ -60,7 +60,7 @@ def foo():
|
|
|
60
60
|
|
|
61
61
|
## Another Real Heading`;
|
|
62
62
|
|
|
63
|
-
const { result } = renderHook(() => useHeadings(content
|
|
63
|
+
const { result } = renderHook(() => useHeadings(content));
|
|
64
64
|
|
|
65
65
|
expect(result.current).toEqual([
|
|
66
66
|
{ id: "real-heading", text: "Real Heading", level: 1 },
|
|
@@ -83,7 +83,7 @@ def foo():
|
|
|
83
83
|
|
|
84
84
|
## Results`;
|
|
85
85
|
|
|
86
|
-
const { result } = renderHook(() => useHeadings(content
|
|
86
|
+
const { result } = renderHook(() => useHeadings(content));
|
|
87
87
|
|
|
88
88
|
expect(result.current).toEqual([
|
|
89
89
|
{ id: "introduction", text: "Introduction", level: 1 },
|
|
@@ -102,7 +102,7 @@ npx readit document.md --port 3000
|
|
|
102
102
|
|
|
103
103
|
## Usage`;
|
|
104
104
|
|
|
105
|
-
const { result } = renderHook(() => useHeadings(content
|
|
105
|
+
const { result } = renderHook(() => useHeadings(content));
|
|
106
106
|
|
|
107
107
|
expect(result.current).toEqual([
|
|
108
108
|
{ id: "setup", text: "Setup", level: 1 },
|
|
@@ -111,49 +111,7 @@ npx readit document.md --port 3000
|
|
|
111
111
|
});
|
|
112
112
|
|
|
113
113
|
it("returns empty array for null content", () => {
|
|
114
|
-
const { result } = renderHook(() => useHeadings(null
|
|
114
|
+
const { result } = renderHook(() => useHeadings(null));
|
|
115
115
|
expect(result.current).toEqual([]);
|
|
116
116
|
});
|
|
117
|
-
|
|
118
|
-
it("returns empty array for null type", () => {
|
|
119
|
-
const { result } = renderHook(() => useHeadings("# Heading", null));
|
|
120
|
-
expect(result.current).toEqual([]);
|
|
121
|
-
});
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
describe("useHeadings - html", () => {
|
|
125
|
-
it("extracts basic headings", () => {
|
|
126
|
-
const content = `<h1>Heading 1</h1>
|
|
127
|
-
<h2>Heading 2</h2>
|
|
128
|
-
<h3>Heading 3</h3>`;
|
|
129
|
-
|
|
130
|
-
const { result } = renderHook(() => useHeadings(content, "html"));
|
|
131
|
-
|
|
132
|
-
expect(result.current).toEqual([
|
|
133
|
-
{ id: "heading-1", text: "Heading 1", level: 1 },
|
|
134
|
-
{ id: "heading-2", text: "Heading 2", level: 2 },
|
|
135
|
-
{ id: "heading-3", text: "Heading 3", level: 3 },
|
|
136
|
-
]);
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it("uses existing id attribute", () => {
|
|
140
|
-
const content = `<h1 id="custom-id">Heading 1</h1>`;
|
|
141
|
-
|
|
142
|
-
const { result } = renderHook(() => useHeadings(content, "html"));
|
|
143
|
-
|
|
144
|
-
expect(result.current).toEqual([
|
|
145
|
-
{ id: "custom-id", text: "Heading 1", level: 1 },
|
|
146
|
-
]);
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it("decodes HTML entities", () => {
|
|
150
|
-
const content = `<h1>Hello & World</h1>`;
|
|
151
|
-
|
|
152
|
-
const { result } = renderHook(() => useHeadings(content, "html"));
|
|
153
|
-
|
|
154
|
-
// Note: & is stripped, leaving "Hello World" → "hello-world" (hyphens collapsed)
|
|
155
|
-
expect(result.current).toEqual([
|
|
156
|
-
{ id: "hello-world", text: "Hello & World", level: 1 },
|
|
157
|
-
]);
|
|
158
|
-
});
|
|
159
117
|
});
|