@peaske7/readit 0.1.8 → 0.2.1
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/.claude/CLAUDE.md +118 -76
- package/.claude/commands/review.md +1 -1
- package/.claude/roadmap.md +32 -9
- package/.claude/user-stories.md +100 -15
- package/AGENTS.md +30 -26
- package/Makefile +32 -0
- package/README.md +90 -5
- package/biome.json +18 -8
- package/bun.lock +426 -710
- package/bunfig.toml +2 -0
- package/docs/perf-baseline.md +130 -0
- package/docs/superpowers/plans/2026-03-26-surgical-pruning.md +1176 -0
- package/docs/superpowers/specs/2026-03-27-go-server-rewrite-design.md +284 -0
- package/e2e/comments.spec.ts +14 -58
- package/e2e/document-load.spec.ts +1 -23
- package/e2e/export.spec.ts +4 -4
- package/e2e/perf/add-comment.spec.ts +116 -0
- package/e2e/perf/fixtures/generate.ts +327 -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/screenshot-final.png +0 -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 +350 -0
- package/e2e/perf/utils/perf-cli.ts +86 -0
- package/e2e/persistence-file.spec.ts +41 -26
- package/e2e/utils/selection.ts +17 -73
- package/go/cmd/readit/main.go +416 -0
- package/go/go.mod +20 -0
- package/go/go.sum +41 -0
- package/go/internal/server/anchor.go +302 -0
- package/go/internal/server/anchor_test.go +111 -0
- package/go/internal/server/comments.go +390 -0
- package/go/internal/server/documents.go +113 -0
- package/go/internal/server/embed.go +17 -0
- package/go/internal/server/headings.go +33 -0
- package/go/internal/server/headings_test.go +75 -0
- package/go/internal/server/htmltext.go +123 -0
- package/go/internal/server/markdown.go +157 -0
- package/go/internal/server/markdown_bench_test.go +42 -0
- package/go/internal/server/markdown_test.go +79 -0
- package/go/internal/server/server.go +453 -0
- package/go/internal/server/server_bench_test.go +122 -0
- package/go/internal/server/settings.go +110 -0
- package/go/internal/server/sse.go +140 -0
- package/go/internal/server/storage.go +275 -0
- package/go/internal/server/storage_test.go +118 -0
- package/go/internal/server/template.go +66 -0
- package/go/internal/server/types.go +101 -0
- package/go/internal/server/watcher.go +74 -0
- package/index.html +4 -14
- package/nvim-readit/lua/readit/health.lua +64 -0
- package/nvim-readit/lua/readit/init.lua +463 -0
- package/nvim-readit/plugin/readit.lua +19 -0
- package/package.json +24 -41
- package/playwright.config.ts +12 -0
- package/shell/_readit +158 -0
- package/shell/readit.zsh +87 -0
- package/src/App.svelte +881 -0
- package/src/{cli/index.ts → cli.ts} +216 -70
- package/src/components/ActionsMenu.svelte +95 -0
- package/src/components/CommentBadge.svelte +67 -0
- package/src/components/CommentErrorBanner.svelte +33 -0
- package/src/components/CommentInput.svelte +75 -0
- package/src/components/CommentListItem.svelte +95 -0
- package/src/components/CommentManager.svelte +129 -0
- package/src/components/CommentNav.svelte +109 -0
- package/src/components/DocumentViewer.svelte +218 -0
- package/src/components/FloatingComment.svelte +107 -0
- package/src/components/Header.svelte +76 -0
- package/src/components/InlineEditor.svelte +72 -0
- package/src/components/MarginNote.svelte +167 -0
- package/src/components/MarginNotesContainer.svelte +33 -0
- package/src/components/RawModal.svelte +126 -0
- package/src/components/ReanchorConfirm.svelte +30 -0
- package/src/components/SettingsModal.svelte +220 -0
- package/src/components/ShortcutCapture.svelte +82 -0
- package/src/components/ShortcutList.svelte +145 -0
- package/src/components/TabBar.svelte +52 -0
- package/src/components/TableOfContents.svelte +125 -0
- package/src/components/ui/ActionLink.svelte +40 -0
- package/src/components/ui/Button.svelte +53 -0
- package/src/components/ui/Dialog.svelte +97 -0
- package/src/components/ui/DropdownMenu.svelte +85 -0
- package/src/components/ui/DropdownMenuItem.svelte +38 -0
- package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
- package/src/components/ui/Text.svelte +42 -0
- package/src/env.d.ts +6 -0
- package/src/index.css +36 -166
- package/src/lib/__fixtures__/bench-data.ts +1 -54
- package/src/lib/anchor.bench.ts +47 -68
- package/src/lib/anchor.test.ts +5 -9
- package/src/lib/anchor.ts +9 -93
- package/src/lib/comment-storage.bench.ts +6 -20
- package/src/lib/comment-storage.test.ts +45 -37
- package/src/lib/comment-storage.ts +23 -64
- package/src/lib/export.bench.ts +9 -23
- package/src/lib/export.ts +7 -14
- package/src/lib/headings.test.ts +103 -0
- package/src/lib/headings.ts +44 -0
- package/src/lib/highlight/core.test.ts +1 -6
- package/src/lib/highlight/dom.ts +53 -280
- package/src/lib/highlight/highlight-registry.ts +221 -0
- package/src/lib/highlight/highlight.bench.ts +92 -0
- package/src/lib/highlight/highlighter.ts +122 -302
- package/src/lib/highlight/{core.ts → resolver.ts} +3 -19
- package/src/lib/highlight/types.ts +0 -40
- package/src/lib/html-text.test.ts +162 -0
- package/src/lib/html-text.ts +161 -0
- package/src/lib/i18n/en.ts +13 -36
- package/src/lib/i18n/ja.ts +14 -37
- package/src/lib/i18n/types.ts +13 -36
- package/src/lib/margin-layout.bench.ts +48 -15
- package/src/lib/margin-layout.ts +2 -31
- package/src/lib/markdown-renderer.test.ts +154 -0
- package/src/lib/markdown-renderer.ts +177 -0
- package/src/lib/mermaid-config.ts +38 -0
- package/src/lib/mermaid-renderer.ts +162 -0
- package/src/lib/mermaid-worker.ts +60 -0
- package/src/lib/positions.ts +157 -0
- package/src/lib/shortcut-registry.ts +138 -103
- package/src/lib/utils.ts +2 -48
- package/src/main.ts +16 -0
- package/src/schema.ts +92 -0
- package/src/{server/index.ts → server.ts} +427 -163
- package/src/stores/app.svelte.ts +231 -0
- package/src/stores/locale.svelte.ts +46 -0
- package/src/stores/settings.svelte.ts +90 -0
- package/src/stores/shortcuts.svelte.ts +104 -0
- package/src/stores/ui.svelte.ts +12 -0
- package/src/template.ts +104 -0
- package/src/test-setup.ts +47 -0
- package/svelte.config.js +5 -0
- package/tsconfig.json +2 -2
- package/vite.config.ts +31 -3
- package/vscode-readit/.mcp.json +7 -0
- package/vscode-readit/.vscodeignore +7 -0
- package/vscode-readit/bun.lock +78 -0
- package/vscode-readit/icon.svg +10 -0
- package/vscode-readit/package.json +110 -0
- package/vscode-readit/src/extension.ts +117 -0
- package/vscode-readit/src/server-manager.ts +272 -0
- package/vscode-readit/src/webview-provider.ts +204 -0
- package/vscode-readit/tsconfig.json +20 -0
- package/e2e/fixtures/sample.html +0 -13
- package/src/App.tsx +0 -416
- package/src/components/ActionsMenu.tsx +0 -112
- package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
- package/src/components/DocumentViewer/DocumentViewer.tsx +0 -259
- package/src/components/DocumentViewer/IframeContainer.tsx +0 -251
- package/src/components/DocumentViewer/InlineCode.tsx +0 -60
- package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -137
- package/src/components/DocumentViewer/index.ts +0 -1
- package/src/components/FloatingTOC.tsx +0 -61
- package/src/components/Header.tsx +0 -65
- package/src/components/InlineEditor.tsx +0 -74
- package/src/components/MarginNote.tsx +0 -207
- package/src/components/MarginNotes.tsx +0 -50
- package/src/components/RawModal.tsx +0 -143
- package/src/components/ReanchorConfirm.tsx +0 -36
- package/src/components/SettingsModal.tsx +0 -310
- package/src/components/ShortcutCapture.tsx +0 -48
- package/src/components/ShortcutList.tsx +0 -198
- package/src/components/TabBar.tsx +0 -60
- package/src/components/TableOfContents.tsx +0 -108
- package/src/components/comments/CommentBadge.tsx +0 -49
- package/src/components/comments/CommentInput.tsx +0 -114
- package/src/components/comments/CommentListItem.tsx +0 -92
- package/src/components/comments/CommentManager.tsx +0 -113
- package/src/components/comments/CommentMinimap.tsx +0 -62
- package/src/components/comments/CommentNav.tsx +0 -109
- package/src/components/ui/ActionBar.tsx +0 -16
- package/src/components/ui/ActionLink.tsx +0 -32
- package/src/components/ui/Button.tsx +0 -55
- package/src/components/ui/Dialog.tsx +0 -156
- package/src/components/ui/DropdownMenu.tsx +0 -114
- package/src/components/ui/SeparatorDot.tsx +0 -9
- package/src/components/ui/Text.tsx +0 -54
- package/src/contexts/CommentContext.tsx +0 -229
- package/src/contexts/LayoutContext.tsx +0 -88
- package/src/contexts/LocaleContext.tsx +0 -35
- package/src/hooks/useClickOutside.ts +0 -35
- package/src/hooks/useClipboard.ts +0 -82
- package/src/hooks/useCommentNavigation.ts +0 -130
- package/src/hooks/useComments.ts +0 -323
- package/src/hooks/useDocument.ts +0 -156
- package/src/hooks/useEditorScheme.ts +0 -51
- package/src/hooks/useFontPreference.ts +0 -59
- package/src/hooks/useHeadings.test.ts +0 -159
- package/src/hooks/useHeadings.ts +0 -129
- 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/useScrollSpy.ts +0 -81
- package/src/hooks/useTextSelection.ts +0 -123
- package/src/hooks/useThemePreference.ts +0 -66
- 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/highlight/colors.ts +0 -37
- 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/scroll.test.ts +0 -118
- package/src/lib/scroll.ts +0 -47
- package/src/lib/shortcut-registry.test.ts +0 -173
- package/src/lib/utils.test.ts +0 -110
- package/src/main.tsx +0 -13
- package/src/store/index.test.ts +0 -242
- package/src/store/index.ts +0 -254
- package/src/types/index.ts +0 -127
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef } from "react";
|
|
2
|
-
import {
|
|
3
|
-
matchesBinding,
|
|
4
|
-
type ShortcutAction,
|
|
5
|
-
type ShortcutDefinition,
|
|
6
|
-
} from "../lib/shortcut-registry";
|
|
7
|
-
|
|
8
|
-
type ActionMap = Partial<Record<ShortcutAction, () => void>>;
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Returns true if the event target is an input element where
|
|
12
|
-
* keyboard shortcuts should be suppressed.
|
|
13
|
-
*/
|
|
14
|
-
function isInputFocused(event: KeyboardEvent): boolean {
|
|
15
|
-
const target = event.target;
|
|
16
|
-
if (!(target instanceof HTMLElement)) return false;
|
|
17
|
-
|
|
18
|
-
const tagName = target.tagName;
|
|
19
|
-
if (tagName === "INPUT" || tagName === "TEXTAREA") return true;
|
|
20
|
-
if (target.isContentEditable) return true;
|
|
21
|
-
|
|
22
|
-
return false;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Single centralized keyboard shortcut listener.
|
|
27
|
-
* Replaces all scattered useEffect keydown handlers.
|
|
28
|
-
*
|
|
29
|
-
* @param shortcuts - Resolved shortcut definitions (from useKeybindings)
|
|
30
|
-
* @param actions - Map of shortcut ID to callback function
|
|
31
|
-
*/
|
|
32
|
-
export function useKeyboardShortcuts(
|
|
33
|
-
shortcuts: ShortcutDefinition[],
|
|
34
|
-
actions: ActionMap,
|
|
35
|
-
): void {
|
|
36
|
-
const actionsRef = useRef(actions);
|
|
37
|
-
actionsRef.current = actions;
|
|
38
|
-
|
|
39
|
-
const shortcutsRef = useRef(shortcuts);
|
|
40
|
-
shortcutsRef.current = shortcuts;
|
|
41
|
-
|
|
42
|
-
useEffect(() => {
|
|
43
|
-
const handleKeyDown = (event: KeyboardEvent) => {
|
|
44
|
-
if (isInputFocused(event)) return;
|
|
45
|
-
|
|
46
|
-
for (const shortcut of shortcutsRef.current) {
|
|
47
|
-
if (!shortcut.enabled) continue;
|
|
48
|
-
|
|
49
|
-
if (matchesBinding(event, shortcut.binding)) {
|
|
50
|
-
const action = actionsRef.current[shortcut.id];
|
|
51
|
-
if (action) {
|
|
52
|
-
event.preventDefault();
|
|
53
|
-
action();
|
|
54
|
-
}
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
window.addEventListener("keydown", handleKeyDown);
|
|
61
|
-
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
62
|
-
}, []);
|
|
63
|
-
}
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { useCallback, useState } from "react";
|
|
2
|
-
import { type LayoutMode, LayoutModes } from "../types";
|
|
3
|
-
|
|
4
|
-
const STORAGE_KEY = "readit:layout-mode";
|
|
5
|
-
|
|
6
|
-
interface UseLayoutModeResult {
|
|
7
|
-
layoutMode: LayoutMode;
|
|
8
|
-
toggleLayoutMode: () => void;
|
|
9
|
-
isFullscreen: boolean;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function useLayoutMode(): UseLayoutModeResult {
|
|
13
|
-
const [layoutMode, setLayoutMode] = useState<LayoutMode>(() => {
|
|
14
|
-
try {
|
|
15
|
-
const stored = localStorage.getItem(STORAGE_KEY);
|
|
16
|
-
return stored === LayoutModes.FULLSCREEN
|
|
17
|
-
? LayoutModes.FULLSCREEN
|
|
18
|
-
: LayoutModes.CENTERED;
|
|
19
|
-
} catch {
|
|
20
|
-
return LayoutModes.CENTERED;
|
|
21
|
-
}
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
const toggleLayoutMode = useCallback(() => {
|
|
25
|
-
setLayoutMode((prev) => {
|
|
26
|
-
const next =
|
|
27
|
-
prev === LayoutModes.CENTERED
|
|
28
|
-
? LayoutModes.FULLSCREEN
|
|
29
|
-
: LayoutModes.CENTERED;
|
|
30
|
-
try {
|
|
31
|
-
localStorage.setItem(STORAGE_KEY, next);
|
|
32
|
-
} catch {
|
|
33
|
-
// localStorage may be unavailable
|
|
34
|
-
}
|
|
35
|
-
return next;
|
|
36
|
-
});
|
|
37
|
-
}, []);
|
|
38
|
-
|
|
39
|
-
return {
|
|
40
|
-
layoutMode,
|
|
41
|
-
toggleLayoutMode,
|
|
42
|
-
isFullscreen: layoutMode === LayoutModes.FULLSCREEN,
|
|
43
|
-
};
|
|
44
|
-
}
|
|
@@ -1,42 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { useCallback } from "react";
|
|
2
|
-
import { appStore, useAppStore } from "../store";
|
|
3
|
-
|
|
4
|
-
interface UseReanchorModeResult {
|
|
5
|
-
reanchorTarget: { commentId: string } | null;
|
|
6
|
-
startReanchor: (commentId: string) => void;
|
|
7
|
-
cancelReanchor: () => void;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Hook for managing re-anchor mode state.
|
|
12
|
-
* When active, the user can select new text to re-anchor an unresolved comment.
|
|
13
|
-
* State lives in the Zustand store for tab-switch preservation.
|
|
14
|
-
*/
|
|
15
|
-
export function useReanchorMode(): UseReanchorModeResult {
|
|
16
|
-
const reanchorTarget = useAppStore(
|
|
17
|
-
(s) => s.getActiveDocumentState()?.reanchorTarget ?? null,
|
|
18
|
-
);
|
|
19
|
-
|
|
20
|
-
const startReanchor = useCallback((commentId: string) => {
|
|
21
|
-
appStore.getState().setReanchorTarget({ commentId });
|
|
22
|
-
}, []);
|
|
23
|
-
|
|
24
|
-
const cancelReanchor = useCallback(() => {
|
|
25
|
-
appStore.getState().setReanchorTarget(null);
|
|
26
|
-
}, []);
|
|
27
|
-
|
|
28
|
-
return {
|
|
29
|
-
reanchorTarget,
|
|
30
|
-
startReanchor,
|
|
31
|
-
cancelReanchor,
|
|
32
|
-
};
|
|
33
|
-
}
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from "react";
|
|
2
|
-
|
|
3
|
-
interface ScrollMetrics {
|
|
4
|
-
documentHeight: number;
|
|
5
|
-
viewportHeight: number;
|
|
6
|
-
scrollTop: number;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Track document scroll and viewport dimensions for minimap calculations.
|
|
11
|
-
* Updates are throttled to once per animation frame to prevent scroll jank.
|
|
12
|
-
*/
|
|
13
|
-
export function useScrollMetrics(): ScrollMetrics {
|
|
14
|
-
const [metrics, setMetrics] = useState<ScrollMetrics>({
|
|
15
|
-
documentHeight: 0,
|
|
16
|
-
viewportHeight: 0,
|
|
17
|
-
scrollTop: 0,
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
const rafIdRef = useRef<number | null>(null);
|
|
21
|
-
|
|
22
|
-
useEffect(() => {
|
|
23
|
-
const updateMetrics = () => {
|
|
24
|
-
setMetrics({
|
|
25
|
-
documentHeight: document.body.scrollHeight,
|
|
26
|
-
viewportHeight: window.innerHeight,
|
|
27
|
-
scrollTop: window.scrollY,
|
|
28
|
-
});
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
// Throttle scroll updates to once per animation frame
|
|
32
|
-
const handleScroll = () => {
|
|
33
|
-
if (rafIdRef.current !== null) return;
|
|
34
|
-
rafIdRef.current = requestAnimationFrame(() => {
|
|
35
|
-
updateMetrics();
|
|
36
|
-
rafIdRef.current = null;
|
|
37
|
-
});
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
// Initial measurement
|
|
41
|
-
updateMetrics();
|
|
42
|
-
|
|
43
|
-
window.addEventListener("scroll", handleScroll, { passive: true });
|
|
44
|
-
window.addEventListener("resize", updateMetrics);
|
|
45
|
-
|
|
46
|
-
return () => {
|
|
47
|
-
window.removeEventListener("scroll", handleScroll);
|
|
48
|
-
window.removeEventListener("resize", updateMetrics);
|
|
49
|
-
if (rafIdRef.current !== null) {
|
|
50
|
-
cancelAnimationFrame(rafIdRef.current);
|
|
51
|
-
}
|
|
52
|
-
};
|
|
53
|
-
}, []);
|
|
54
|
-
|
|
55
|
-
return metrics;
|
|
56
|
-
}
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from "react";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Hook to track which heading is currently in view
|
|
5
|
-
* Uses IntersectionObserver to detect when headings enter the "active zone"
|
|
6
|
-
*/
|
|
7
|
-
export function useScrollSpy(headingIds: string[]): string | null {
|
|
8
|
-
const [activeId, setActiveId] = useState<string | null>(null);
|
|
9
|
-
const hasSetInitialRef = useRef(false);
|
|
10
|
-
|
|
11
|
-
useEffect(() => {
|
|
12
|
-
if (headingIds.length === 0) {
|
|
13
|
-
setActiveId(null);
|
|
14
|
-
hasSetInitialRef.current = false;
|
|
15
|
-
return;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// Track visible headings and their positions
|
|
19
|
-
const visibleHeadings = new Map<string, number>();
|
|
20
|
-
|
|
21
|
-
const observer = new IntersectionObserver(
|
|
22
|
-
(entries) => {
|
|
23
|
-
for (const entry of entries) {
|
|
24
|
-
const id = entry.target.id;
|
|
25
|
-
|
|
26
|
-
if (entry.isIntersecting) {
|
|
27
|
-
// Store the top position when heading becomes visible
|
|
28
|
-
visibleHeadings.set(id, entry.boundingClientRect.top);
|
|
29
|
-
} else {
|
|
30
|
-
visibleHeadings.delete(id);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Find the heading closest to the top of the viewport
|
|
35
|
-
if (visibleHeadings.size > 0) {
|
|
36
|
-
let closestId: string | null = null;
|
|
37
|
-
let closestDistance = Number.POSITIVE_INFINITY;
|
|
38
|
-
|
|
39
|
-
for (const [id, top] of visibleHeadings) {
|
|
40
|
-
// Prefer headings that are near the top but still visible
|
|
41
|
-
const distance = Math.abs(top);
|
|
42
|
-
if (distance < closestDistance) {
|
|
43
|
-
closestDistance = distance;
|
|
44
|
-
closestId = id;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
if (closestId) {
|
|
49
|
-
setActiveId(closestId);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
},
|
|
53
|
-
{
|
|
54
|
-
// Observe when headings are in the top 30% of viewport
|
|
55
|
-
rootMargin: "-10% 0px -70% 0px",
|
|
56
|
-
threshold: 0,
|
|
57
|
-
},
|
|
58
|
-
);
|
|
59
|
-
|
|
60
|
-
// Set initial active heading BEFORE starting observer
|
|
61
|
-
// to prevent flash when observer fires first
|
|
62
|
-
if (!hasSetInitialRef.current) {
|
|
63
|
-
setActiveId(headingIds[0]);
|
|
64
|
-
hasSetInitialRef.current = true;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// Observe all headings
|
|
68
|
-
for (const id of headingIds) {
|
|
69
|
-
const element = document.getElementById(id);
|
|
70
|
-
if (element) {
|
|
71
|
-
observer.observe(element);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return () => {
|
|
76
|
-
observer.disconnect();
|
|
77
|
-
};
|
|
78
|
-
}, [headingIds]);
|
|
79
|
-
|
|
80
|
-
return activeId;
|
|
81
|
-
}
|
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
import { useCallback, useEffect } from "react";
|
|
2
|
-
import { appStore, useAppStore } from "../store";
|
|
3
|
-
import type { Selection } from "../types";
|
|
4
|
-
|
|
5
|
-
/** Remove pending highlight marks from the DOM without triggering a full clear/reapply cycle. */
|
|
6
|
-
function clearPendingMarks() {
|
|
7
|
-
for (const mark of document.querySelectorAll("mark[data-pending]")) {
|
|
8
|
-
const parent = mark.parentNode;
|
|
9
|
-
if (!parent) continue;
|
|
10
|
-
while (mark.firstChild) parent.insertBefore(mark.firstChild, mark);
|
|
11
|
-
parent.removeChild(mark);
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
interface UseTextSelectionResult {
|
|
16
|
-
selection: Selection | null;
|
|
17
|
-
highlightPositions: Record<string, number>;
|
|
18
|
-
documentPositions: Record<string, number>;
|
|
19
|
-
pendingSelectionTop: number | undefined;
|
|
20
|
-
onTextSelect: (
|
|
21
|
-
text: string,
|
|
22
|
-
startOffset: number,
|
|
23
|
-
endOffset: number,
|
|
24
|
-
selectionTop: number,
|
|
25
|
-
) => void;
|
|
26
|
-
onPositionsChange: (
|
|
27
|
-
positions: Record<string, number>,
|
|
28
|
-
docPositions: Record<string, number>,
|
|
29
|
-
pendingTop?: number,
|
|
30
|
-
) => void;
|
|
31
|
-
clearSelection: () => void;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Manage text selection state, highlight positions, and click-outside dismissal.
|
|
36
|
-
* State lives in the Zustand store for tab-switch preservation.
|
|
37
|
-
*/
|
|
38
|
-
export function useTextSelection(): UseTextSelectionResult {
|
|
39
|
-
const selection = useAppStore(
|
|
40
|
-
(s) => s.getActiveDocumentState()?.selection ?? null,
|
|
41
|
-
);
|
|
42
|
-
const highlightPositions = useAppStore(
|
|
43
|
-
(s) => s.getActiveDocumentState()?.highlightPositions ?? {},
|
|
44
|
-
);
|
|
45
|
-
const documentPositions = useAppStore(
|
|
46
|
-
(s) => s.getActiveDocumentState()?.documentPositions ?? {},
|
|
47
|
-
);
|
|
48
|
-
const pendingSelectionTop = useAppStore(
|
|
49
|
-
(s) => s.getActiveDocumentState()?.pendingSelectionTop,
|
|
50
|
-
);
|
|
51
|
-
|
|
52
|
-
useEffect(() => {
|
|
53
|
-
if (!selection) return;
|
|
54
|
-
|
|
55
|
-
const handleClickOutside = (e: MouseEvent) => {
|
|
56
|
-
const target = e.target as HTMLElement;
|
|
57
|
-
|
|
58
|
-
// Don't clear if clicking inside the comment input area
|
|
59
|
-
if (target.closest("[data-comment-input]")) return;
|
|
60
|
-
|
|
61
|
-
// Don't clear if clicking on any highlight (pending or comment)
|
|
62
|
-
if (target.closest("mark[data-pending]")) return;
|
|
63
|
-
if (target.closest("mark[data-comment-id]")) return;
|
|
64
|
-
|
|
65
|
-
// Clear selection state and pending marks
|
|
66
|
-
appStore.getState().setSelection(null);
|
|
67
|
-
appStore.getState().setPendingSelectionTop(undefined);
|
|
68
|
-
clearPendingMarks();
|
|
69
|
-
requestAnimationFrame(() => {
|
|
70
|
-
const sel = window.getSelection();
|
|
71
|
-
if (sel?.isCollapsed) {
|
|
72
|
-
sel.removeAllRanges();
|
|
73
|
-
}
|
|
74
|
-
});
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
// Use mousedown to catch clicks before text selection
|
|
78
|
-
document.addEventListener("mousedown", handleClickOutside);
|
|
79
|
-
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
80
|
-
}, [selection]);
|
|
81
|
-
|
|
82
|
-
const onTextSelect = useCallback(
|
|
83
|
-
(
|
|
84
|
-
text: string,
|
|
85
|
-
startOffset: number,
|
|
86
|
-
endOffset: number,
|
|
87
|
-
selectionTop: number,
|
|
88
|
-
) => {
|
|
89
|
-
appStore.getState().setSelection({ text, startOffset, endOffset });
|
|
90
|
-
appStore.getState().setPendingSelectionTop(selectionTop);
|
|
91
|
-
},
|
|
92
|
-
[],
|
|
93
|
-
);
|
|
94
|
-
|
|
95
|
-
const onPositionsChange = useCallback(
|
|
96
|
-
(
|
|
97
|
-
positions: Record<string, number>,
|
|
98
|
-
docPositions: Record<string, number>,
|
|
99
|
-
_pendingTop?: number,
|
|
100
|
-
) => {
|
|
101
|
-
appStore.getState().setHighlightPositions(positions);
|
|
102
|
-
appStore.getState().setDocumentPositions(docPositions);
|
|
103
|
-
},
|
|
104
|
-
[],
|
|
105
|
-
);
|
|
106
|
-
|
|
107
|
-
const clearSelection = useCallback(() => {
|
|
108
|
-
appStore.getState().setSelection(null);
|
|
109
|
-
appStore.getState().setPendingSelectionTop(undefined);
|
|
110
|
-
clearPendingMarks();
|
|
111
|
-
window.getSelection()?.removeAllRanges();
|
|
112
|
-
}, []);
|
|
113
|
-
|
|
114
|
-
return {
|
|
115
|
-
selection,
|
|
116
|
-
highlightPositions,
|
|
117
|
-
documentPositions,
|
|
118
|
-
pendingSelectionTop,
|
|
119
|
-
onTextSelect,
|
|
120
|
-
onPositionsChange,
|
|
121
|
-
clearSelection,
|
|
122
|
-
};
|
|
123
|
-
}
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import { useCallback, useEffect, useState } from "react";
|
|
2
|
-
import { type ThemeMode, ThemeModes } from "../types";
|
|
3
|
-
|
|
4
|
-
const STORAGE_KEY = "readit:theme";
|
|
5
|
-
|
|
6
|
-
const DARK_MQ = "(prefers-color-scheme: dark)";
|
|
7
|
-
|
|
8
|
-
function getStoredTheme(): ThemeMode {
|
|
9
|
-
try {
|
|
10
|
-
const stored = localStorage.getItem(STORAGE_KEY);
|
|
11
|
-
if (
|
|
12
|
-
stored === ThemeModes.LIGHT ||
|
|
13
|
-
stored === ThemeModes.DARK ||
|
|
14
|
-
stored === ThemeModes.SYSTEM
|
|
15
|
-
) {
|
|
16
|
-
return stored;
|
|
17
|
-
}
|
|
18
|
-
} catch {
|
|
19
|
-
// localStorage may be unavailable
|
|
20
|
-
}
|
|
21
|
-
return ThemeModes.SYSTEM;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function applyTheme(mode: ThemeMode): void {
|
|
25
|
-
const isDark =
|
|
26
|
-
mode === ThemeModes.DARK ||
|
|
27
|
-
(mode === ThemeModes.SYSTEM && window.matchMedia(DARK_MQ).matches);
|
|
28
|
-
|
|
29
|
-
document.documentElement.classList.toggle("dark", isDark);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
interface UseThemePreferenceResult {
|
|
33
|
-
themeMode: ThemeMode;
|
|
34
|
-
setThemeMode: (mode: ThemeMode) => void;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function useThemePreference(): UseThemePreferenceResult {
|
|
38
|
-
const [themeMode, setThemeModeState] = useState<ThemeMode>(getStoredTheme);
|
|
39
|
-
|
|
40
|
-
// Apply theme class whenever mode changes
|
|
41
|
-
useEffect(() => {
|
|
42
|
-
applyTheme(themeMode);
|
|
43
|
-
}, [themeMode]);
|
|
44
|
-
|
|
45
|
-
// Listen for system preference changes when in "system" mode
|
|
46
|
-
useEffect(() => {
|
|
47
|
-
if (themeMode !== ThemeModes.SYSTEM) return;
|
|
48
|
-
|
|
49
|
-
const mq = window.matchMedia(DARK_MQ);
|
|
50
|
-
const handler = () => applyTheme(ThemeModes.SYSTEM);
|
|
51
|
-
|
|
52
|
-
mq.addEventListener("change", handler);
|
|
53
|
-
return () => mq.removeEventListener("change", handler);
|
|
54
|
-
}, [themeMode]);
|
|
55
|
-
|
|
56
|
-
const setThemeMode = useCallback((mode: ThemeMode) => {
|
|
57
|
-
setThemeModeState(mode);
|
|
58
|
-
try {
|
|
59
|
-
localStorage.setItem(STORAGE_KEY, mode);
|
|
60
|
-
} catch {
|
|
61
|
-
// localStorage may be unavailable
|
|
62
|
-
}
|
|
63
|
-
}, []);
|
|
64
|
-
|
|
65
|
-
return { themeMode, setThemeMode };
|
|
66
|
-
}
|
package/src/lib/context.bench.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { bench, describe } from "vitest";
|
|
2
|
-
import { HTML_DOC, LARGE_DOC } from "./__fixtures__/bench-data";
|
|
3
|
-
import { extractContext, formatForLLM, stripHtmlTags } from "./context";
|
|
4
|
-
|
|
5
|
-
describe("stripHtmlTags", () => {
|
|
6
|
-
bench("200-line HTML document", () => {
|
|
7
|
-
stripHtmlTags(HTML_DOC);
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
bench("short HTML string", () => {
|
|
11
|
-
stripHtmlTags(
|
|
12
|
-
"<p>Hello & <strong>world</strong> with <entities></p>",
|
|
13
|
-
);
|
|
14
|
-
});
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
describe("extractContext", () => {
|
|
18
|
-
bench("single-line selection, markdown", () => {
|
|
19
|
-
extractContext({ content: LARGE_DOC, startOffset: 500, endOffset: 530 });
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
bench("multi-line selection, markdown", () => {
|
|
23
|
-
extractContext({ content: LARGE_DOC, startOffset: 500, endOffset: 800 });
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
bench("HTML content (triggers stripHtmlTags)", () => {
|
|
27
|
-
extractContext({ content: HTML_DOC, startOffset: 100, endOffset: 200 });
|
|
28
|
-
});
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
describe("formatForLLM", () => {
|
|
32
|
-
const ctx = extractContext({
|
|
33
|
-
content: LARGE_DOC,
|
|
34
|
-
startOffset: 500,
|
|
35
|
-
endOffset: 530,
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
bench("format with comment", () => {
|
|
39
|
-
formatForLLM({ context: ctx, fileName: "doc.md", comment: "Needs review" });
|
|
40
|
-
});
|
|
41
|
-
});
|