@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
package/src/hooks/useDocument.ts
DELETED
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
import { useCallback, useEffect, useState } from "react";
|
|
2
|
-
import { toast } from "sonner";
|
|
3
|
-
import { appStore, useAppStore } from "../store";
|
|
4
|
-
import type { Document } from "../types";
|
|
5
|
-
|
|
6
|
-
interface UseDocumentResult {
|
|
7
|
-
document: Document | null;
|
|
8
|
-
error: string | null;
|
|
9
|
-
isInitialized: boolean;
|
|
10
|
-
reload: () => Promise<void>;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
interface DocListItem {
|
|
14
|
-
path: string;
|
|
15
|
-
fileName: string;
|
|
16
|
-
type: Document["type"];
|
|
17
|
-
}
|
|
18
|
-
|
|
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
|
-
export function useDocument(): UseDocumentResult {
|
|
27
|
-
const [error, setError] = useState<string | null>(null);
|
|
28
|
-
const [isInitialized, setIsInitialized] = useState(false);
|
|
29
|
-
|
|
30
|
-
const activeDocumentPath = useAppStore((s) => s.activeDocumentPath);
|
|
31
|
-
|
|
32
|
-
// Active document — null until content is loaded
|
|
33
|
-
const document = useAppStore((s) => {
|
|
34
|
-
const ds = s.getActiveDocumentState();
|
|
35
|
-
if (!ds || !ds.document.content) return null;
|
|
36
|
-
return ds.document;
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
// Fetch document list on mount, populate store
|
|
40
|
-
useEffect(() => {
|
|
41
|
-
async function init() {
|
|
42
|
-
try {
|
|
43
|
-
const res = await fetch("/api/documents");
|
|
44
|
-
if (!res.ok) throw new Error(`Server error: ${res.status}`);
|
|
45
|
-
const data = await res.json();
|
|
46
|
-
|
|
47
|
-
const clean = data.clean || false;
|
|
48
|
-
if (data.workingDirectory) {
|
|
49
|
-
appStore.getState().setWorkingDirectory(data.workingDirectory);
|
|
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
|
-
});
|
|
63
|
-
} catch (err) {
|
|
64
|
-
setError(
|
|
65
|
-
err instanceof Error ? err.message : "Failed to load documents",
|
|
66
|
-
);
|
|
67
|
-
} finally {
|
|
68
|
-
setIsInitialized(true);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
init();
|
|
72
|
-
}, []);
|
|
73
|
-
|
|
74
|
-
// Load content when active document changes and has no content yet
|
|
75
|
-
useEffect(() => {
|
|
76
|
-
if (!activeDocumentPath) return;
|
|
77
|
-
const state = appStore.getState().documents.get(activeDocumentPath);
|
|
78
|
-
if (!state || state.document.content) return;
|
|
79
|
-
|
|
80
|
-
async function loadContent() {
|
|
81
|
-
try {
|
|
82
|
-
const res = await fetch(
|
|
83
|
-
`/api/document?path=${encodeURIComponent(activeDocumentPath!)}`,
|
|
84
|
-
);
|
|
85
|
-
if (!res.ok) throw new Error(`Server error: ${res.status}`);
|
|
86
|
-
const data = await res.json();
|
|
87
|
-
appStore
|
|
88
|
-
.getState()
|
|
89
|
-
.updateDocumentContent(data.content, activeDocumentPath!);
|
|
90
|
-
} catch (err) {
|
|
91
|
-
setError(
|
|
92
|
-
err instanceof Error ? err.message : "Failed to load document",
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
loadContent();
|
|
97
|
-
}, [activeDocumentPath]);
|
|
98
|
-
|
|
99
|
-
// SSE: register new documents without stealing focus; reload loaded docs on updates
|
|
100
|
-
useEffect(() => {
|
|
101
|
-
const eventSource = new EventSource("/api/document/stream");
|
|
102
|
-
eventSource.onmessage = async (e) => {
|
|
103
|
-
try {
|
|
104
|
-
const data = JSON.parse(e.data);
|
|
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) {
|
|
119
|
-
// Only reload if content was previously loaded
|
|
120
|
-
const state = appStore.getState().documents.get(data.path);
|
|
121
|
-
if (!state || !state.document.content) return;
|
|
122
|
-
|
|
123
|
-
const res = await fetch(
|
|
124
|
-
`/api/document?path=${encodeURIComponent(data.path)}`,
|
|
125
|
-
);
|
|
126
|
-
if (res.ok) {
|
|
127
|
-
const doc = await res.json();
|
|
128
|
-
appStore.getState().updateDocumentContent(doc.content, data.path);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
} catch {
|
|
132
|
-
// Ignore non-JSON messages ("connected", "ping")
|
|
133
|
-
}
|
|
134
|
-
};
|
|
135
|
-
return () => eventSource.close();
|
|
136
|
-
}, []);
|
|
137
|
-
|
|
138
|
-
const reload = useCallback(async () => {
|
|
139
|
-
if (!activeDocumentPath) return;
|
|
140
|
-
try {
|
|
141
|
-
const res = await fetch(
|
|
142
|
-
`/api/document?path=${encodeURIComponent(activeDocumentPath)}`,
|
|
143
|
-
);
|
|
144
|
-
if (!res.ok) throw new Error(`Server error: ${res.status}`);
|
|
145
|
-
const data = await res.json();
|
|
146
|
-
appStore
|
|
147
|
-
.getState()
|
|
148
|
-
.updateDocumentContent(data.content, activeDocumentPath);
|
|
149
|
-
toast.success("Document reloaded");
|
|
150
|
-
} catch (err) {
|
|
151
|
-
toast.error(err instanceof Error ? err.message : "Failed to reload");
|
|
152
|
-
}
|
|
153
|
-
}, [activeDocumentPath]);
|
|
154
|
-
|
|
155
|
-
return { document, error, isInitialized, reload };
|
|
156
|
-
}
|
|
@@ -1,51 +0,0 @@
|
|
|
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,59 +0,0 @@
|
|
|
1
|
-
import { useCallback, useEffect, useState } from "react";
|
|
2
|
-
import { toast } from "sonner";
|
|
3
|
-
import { FontFamilies, type FontFamily } from "../types";
|
|
4
|
-
|
|
5
|
-
interface UseFontPreferenceResult {
|
|
6
|
-
fontFamily: FontFamily;
|
|
7
|
-
setFontFamily: (font: FontFamily) => Promise<void>;
|
|
8
|
-
isLoading: boolean;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function useFontPreference(): UseFontPreferenceResult {
|
|
12
|
-
const [fontFamily, setFontFamilyState] = useState<FontFamily>(
|
|
13
|
-
FontFamilies.SERIF,
|
|
14
|
-
);
|
|
15
|
-
const [isLoading, setIsLoading] = useState(true);
|
|
16
|
-
|
|
17
|
-
useEffect(() => {
|
|
18
|
-
const fetchSettings = async () => {
|
|
19
|
-
try {
|
|
20
|
-
const response = await fetch("/api/settings");
|
|
21
|
-
if (response.ok) {
|
|
22
|
-
const settings = await response.json();
|
|
23
|
-
setFontFamilyState(settings.fontFamily || FontFamilies.SERIF);
|
|
24
|
-
}
|
|
25
|
-
} catch (err) {
|
|
26
|
-
console.error("Failed to fetch settings:", err);
|
|
27
|
-
} finally {
|
|
28
|
-
setIsLoading(false);
|
|
29
|
-
}
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
fetchSettings();
|
|
33
|
-
}, []);
|
|
34
|
-
|
|
35
|
-
const setFontFamily = useCallback(async (font: FontFamily) => {
|
|
36
|
-
setFontFamilyState(font);
|
|
37
|
-
|
|
38
|
-
try {
|
|
39
|
-
const response = await fetch("/api/settings", {
|
|
40
|
-
method: "PUT",
|
|
41
|
-
headers: { "Content-Type": "application/json" },
|
|
42
|
-
body: JSON.stringify({ fontFamily: font }),
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
if (!response.ok) {
|
|
46
|
-
throw new Error("Failed to save settings");
|
|
47
|
-
}
|
|
48
|
-
} catch (err) {
|
|
49
|
-
console.error("Failed to save font preference:", err);
|
|
50
|
-
toast.error("Failed to save font preference");
|
|
51
|
-
}
|
|
52
|
-
}, []);
|
|
53
|
-
|
|
54
|
-
return {
|
|
55
|
-
fontFamily,
|
|
56
|
-
setFontFamily,
|
|
57
|
-
isLoading,
|
|
58
|
-
};
|
|
59
|
-
}
|
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
import { renderHook } from "@testing-library/react";
|
|
2
|
-
import { describe, expect, it } from "vitest";
|
|
3
|
-
import { useHeadings } from "./useHeadings";
|
|
4
|
-
|
|
5
|
-
describe("useHeadings - markdown", () => {
|
|
6
|
-
it("extracts basic headings", () => {
|
|
7
|
-
const content = `# Heading 1
|
|
8
|
-
## Heading 2
|
|
9
|
-
### Heading 3`;
|
|
10
|
-
|
|
11
|
-
const { result } = renderHook(() => useHeadings(content, "markdown"));
|
|
12
|
-
|
|
13
|
-
expect(result.current).toEqual([
|
|
14
|
-
{ id: "heading-1", text: "Heading 1", level: 1 },
|
|
15
|
-
{ id: "heading-2", text: "Heading 2", level: 2 },
|
|
16
|
-
{ id: "heading-3", text: "Heading 3", level: 3 },
|
|
17
|
-
]);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it("handles duplicate headings", () => {
|
|
21
|
-
const content = `## Section
|
|
22
|
-
## Section
|
|
23
|
-
## Section`;
|
|
24
|
-
|
|
25
|
-
const { result } = renderHook(() => useHeadings(content, "markdown"));
|
|
26
|
-
|
|
27
|
-
expect(result.current).toEqual([
|
|
28
|
-
{ id: "section", text: "Section", level: 2 },
|
|
29
|
-
{ id: "section-1", text: "Section", level: 2 },
|
|
30
|
-
{ id: "section-2", text: "Section", level: 2 },
|
|
31
|
-
]);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("ignores headings inside fenced code blocks", () => {
|
|
35
|
-
const content = `# Real Heading
|
|
36
|
-
|
|
37
|
-
\`\`\`bash
|
|
38
|
-
# This is a comment, not a heading
|
|
39
|
-
echo "hello"
|
|
40
|
-
\`\`\`
|
|
41
|
-
|
|
42
|
-
## Another Real Heading`;
|
|
43
|
-
|
|
44
|
-
const { result } = renderHook(() => useHeadings(content, "markdown"));
|
|
45
|
-
|
|
46
|
-
expect(result.current).toEqual([
|
|
47
|
-
{ id: "real-heading", text: "Real Heading", level: 1 },
|
|
48
|
-
{ id: "another-real-heading", text: "Another Real Heading", level: 2 },
|
|
49
|
-
]);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("ignores headings inside triple-tilde code blocks", () => {
|
|
53
|
-
const content = `# Real Heading
|
|
54
|
-
|
|
55
|
-
~~~python
|
|
56
|
-
# Python comment
|
|
57
|
-
def foo():
|
|
58
|
-
pass
|
|
59
|
-
~~~
|
|
60
|
-
|
|
61
|
-
## Another Real Heading`;
|
|
62
|
-
|
|
63
|
-
const { result } = renderHook(() => useHeadings(content, "markdown"));
|
|
64
|
-
|
|
65
|
-
expect(result.current).toEqual([
|
|
66
|
-
{ id: "real-heading", text: "Real Heading", level: 1 },
|
|
67
|
-
{ id: "another-real-heading", text: "Another Real Heading", level: 2 },
|
|
68
|
-
]);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it("handles multiple code blocks", () => {
|
|
72
|
-
const content = `# Introduction
|
|
73
|
-
|
|
74
|
-
\`\`\`bash
|
|
75
|
-
# Comment 1
|
|
76
|
-
\`\`\`
|
|
77
|
-
|
|
78
|
-
## Methods
|
|
79
|
-
|
|
80
|
-
\`\`\`python
|
|
81
|
-
# Comment 2
|
|
82
|
-
\`\`\`
|
|
83
|
-
|
|
84
|
-
## Results`;
|
|
85
|
-
|
|
86
|
-
const { result } = renderHook(() => useHeadings(content, "markdown"));
|
|
87
|
-
|
|
88
|
-
expect(result.current).toEqual([
|
|
89
|
-
{ id: "introduction", text: "Introduction", level: 1 },
|
|
90
|
-
{ id: "methods", text: "Methods", level: 2 },
|
|
91
|
-
{ id: "results", text: "Results", level: 2 },
|
|
92
|
-
]);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it("handles code block with language specifier", () => {
|
|
96
|
-
const content = `# Setup
|
|
97
|
-
|
|
98
|
-
\`\`\`bash
|
|
99
|
-
# Use a custom port
|
|
100
|
-
npx readit document.md --port 3000
|
|
101
|
-
\`\`\`
|
|
102
|
-
|
|
103
|
-
## Usage`;
|
|
104
|
-
|
|
105
|
-
const { result } = renderHook(() => useHeadings(content, "markdown"));
|
|
106
|
-
|
|
107
|
-
expect(result.current).toEqual([
|
|
108
|
-
{ id: "setup", text: "Setup", level: 1 },
|
|
109
|
-
{ id: "usage", text: "Usage", level: 2 },
|
|
110
|
-
]);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it("returns empty array for null content", () => {
|
|
114
|
-
const { result } = renderHook(() => useHeadings(null, "markdown"));
|
|
115
|
-
expect(result.current).toEqual([]);
|
|
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
|
-
});
|
package/src/hooks/useHeadings.ts
DELETED
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
import { useMemo } from "react";
|
|
2
|
-
import { slugify } from "../lib/utils";
|
|
3
|
-
import type { DocumentType } from "../types";
|
|
4
|
-
|
|
5
|
-
export interface Heading {
|
|
6
|
-
id: string;
|
|
7
|
-
text: string;
|
|
8
|
-
level: 1 | 2 | 3 | 4 | 5 | 6;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Remove code blocks from markdown content.
|
|
13
|
-
* Handles both fenced (```) and indented (4 spaces) code blocks.
|
|
14
|
-
*/
|
|
15
|
-
function stripCodeBlocks(content: string): string {
|
|
16
|
-
// Remove fenced code blocks (``` or ~~~)
|
|
17
|
-
let result = content.replace(/^(`{3,}|~{3,}).*$[\s\S]*?^\1\s*$/gm, "");
|
|
18
|
-
|
|
19
|
-
// Remove indented code blocks (4 spaces or 1 tab at start of line)
|
|
20
|
-
// Only remove if preceded by a blank line (to avoid removing list items)
|
|
21
|
-
result = result.replace(/(?:^|\n\n)((?:(?:[ ]{4}|\t).+\n?)+)/g, "\n\n");
|
|
22
|
-
|
|
23
|
-
return result;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Extract headings from markdown content
|
|
28
|
-
*/
|
|
29
|
-
function parseMarkdownHeadings(content: string): Heading[] {
|
|
30
|
-
const headings: Heading[] = [];
|
|
31
|
-
const seenIds = new Map<string, number>();
|
|
32
|
-
|
|
33
|
-
// Strip code blocks to avoid matching # comments in code
|
|
34
|
-
const contentWithoutCode = stripCodeBlocks(content);
|
|
35
|
-
|
|
36
|
-
const regex = /^(#{1,6})\s+(.+)$/gm;
|
|
37
|
-
let match = regex.exec(contentWithoutCode);
|
|
38
|
-
|
|
39
|
-
while (match !== null) {
|
|
40
|
-
const level = match[1].length as 1 | 2 | 3 | 4 | 5 | 6;
|
|
41
|
-
const text = match[2].trim();
|
|
42
|
-
const baseId = slugify(text);
|
|
43
|
-
const count = seenIds.get(baseId) ?? 0;
|
|
44
|
-
const id = count > 0 ? `${baseId}-${count}` : baseId;
|
|
45
|
-
seenIds.set(baseId, count + 1);
|
|
46
|
-
|
|
47
|
-
headings.push({ id, text, level });
|
|
48
|
-
match = regex.exec(contentWithoutCode);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
return headings;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Generate ID matching the iframe's ensureHeadingIds algorithm.
|
|
56
|
-
* Note: This differs from utils/slugify - it strips underscores to match
|
|
57
|
-
* the iframe script's ID generation exactly.
|
|
58
|
-
*/
|
|
59
|
-
function generateHeadingId(text: string): string {
|
|
60
|
-
return text
|
|
61
|
-
.toLowerCase()
|
|
62
|
-
.trim()
|
|
63
|
-
.replace(/[^a-z0-9 -]/g, "")
|
|
64
|
-
.replace(/ +/g, "-")
|
|
65
|
-
.replace(/-+/g, "-");
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Extract headings from HTML content
|
|
70
|
-
*/
|
|
71
|
-
function parseHtmlHeadings(content: string): Heading[] {
|
|
72
|
-
const headings: Heading[] = [];
|
|
73
|
-
const seenIds = new Map<string, number>();
|
|
74
|
-
|
|
75
|
-
// Match h1-h6 tags, capturing attributes and text content
|
|
76
|
-
const regex = /<h([1-6])([^>]*)>([^<]+)<\/h\1>/gi;
|
|
77
|
-
let match = regex.exec(content);
|
|
78
|
-
|
|
79
|
-
while (match !== null) {
|
|
80
|
-
const level = Number.parseInt(match[1], 10) as 1 | 2 | 3 | 4 | 5 | 6;
|
|
81
|
-
const attributes = match[2];
|
|
82
|
-
// Strip any remaining HTML tags and decode entities
|
|
83
|
-
const text = match[3]
|
|
84
|
-
.replace(/<[^>]+>/g, "")
|
|
85
|
-
.replace(/&/g, "&")
|
|
86
|
-
.replace(/</g, "<")
|
|
87
|
-
.replace(/>/g, ">")
|
|
88
|
-
.replace(/"/g, '"')
|
|
89
|
-
.trim();
|
|
90
|
-
|
|
91
|
-
if (text) {
|
|
92
|
-
// Extract existing id attribute if present
|
|
93
|
-
const idMatch = /\sid=["']([^"']+)["']/i.exec(attributes);
|
|
94
|
-
|
|
95
|
-
// Use existing ID or generate one with duplicate handling
|
|
96
|
-
const id = idMatch
|
|
97
|
-
? idMatch[1]
|
|
98
|
-
: (() => {
|
|
99
|
-
const baseId = generateHeadingId(text);
|
|
100
|
-
const count = seenIds.get(baseId) ?? 0;
|
|
101
|
-
seenIds.set(baseId, count + 1);
|
|
102
|
-
return count > 0 ? `${baseId}-${count}` : baseId;
|
|
103
|
-
})();
|
|
104
|
-
|
|
105
|
-
headings.push({ id, text, level });
|
|
106
|
-
}
|
|
107
|
-
match = regex.exec(content);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
return headings;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Hook to extract headings from document content
|
|
115
|
-
*/
|
|
116
|
-
export function useHeadings(
|
|
117
|
-
content: string | null,
|
|
118
|
-
type: DocumentType | null,
|
|
119
|
-
): Heading[] {
|
|
120
|
-
return useMemo(() => {
|
|
121
|
-
if (!content || !type) return [];
|
|
122
|
-
|
|
123
|
-
if (type === "markdown") {
|
|
124
|
-
return parseMarkdownHeadings(content);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
return parseHtmlHeadings(content);
|
|
128
|
-
}, [content, type]);
|
|
129
|
-
}
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
import { useCallback, useEffect, useState } from "react";
|
|
2
|
-
import { toast } from "sonner";
|
|
3
|
-
import {
|
|
4
|
-
resolveShortcuts,
|
|
5
|
-
type ShortcutDefinition,
|
|
6
|
-
} from "../lib/shortcut-registry";
|
|
7
|
-
import type { KeybindingOverride, ShortcutBinding } from "../types";
|
|
8
|
-
|
|
9
|
-
interface UseKeybindingsResult {
|
|
10
|
-
shortcuts: ShortcutDefinition[];
|
|
11
|
-
updateBinding: (id: string, binding: ShortcutBinding) => Promise<void>;
|
|
12
|
-
toggleEnabled: (id: string) => Promise<void>;
|
|
13
|
-
resetToDefaults: () => Promise<void>;
|
|
14
|
-
isLoading: boolean;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function useKeybindings(): UseKeybindingsResult {
|
|
18
|
-
const [overrides, setOverrides] = useState<KeybindingOverride[]>([]);
|
|
19
|
-
const [isLoading, setIsLoading] = useState(true);
|
|
20
|
-
|
|
21
|
-
useEffect(() => {
|
|
22
|
-
const fetchKeybindings = async () => {
|
|
23
|
-
try {
|
|
24
|
-
const response = await fetch("/api/settings");
|
|
25
|
-
if (response.ok) {
|
|
26
|
-
const settings = await response.json();
|
|
27
|
-
setOverrides(settings.keybindings ?? []);
|
|
28
|
-
}
|
|
29
|
-
} catch (err) {
|
|
30
|
-
console.error("Failed to fetch keybindings:", err);
|
|
31
|
-
} finally {
|
|
32
|
-
setIsLoading(false);
|
|
33
|
-
}
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
fetchKeybindings();
|
|
37
|
-
}, []);
|
|
38
|
-
|
|
39
|
-
const persistOverrides = useCallback(
|
|
40
|
-
async (newOverrides: KeybindingOverride[]) => {
|
|
41
|
-
try {
|
|
42
|
-
const response = await fetch("/api/settings");
|
|
43
|
-
if (!response.ok) return;
|
|
44
|
-
|
|
45
|
-
const currentSettings = await response.json();
|
|
46
|
-
const updated = { ...currentSettings, keybindings: newOverrides };
|
|
47
|
-
|
|
48
|
-
const putResponse = await fetch("/api/settings", {
|
|
49
|
-
method: "PUT",
|
|
50
|
-
headers: { "Content-Type": "application/json" },
|
|
51
|
-
body: JSON.stringify(updated),
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
if (!putResponse.ok) {
|
|
55
|
-
throw new Error("Failed to save keybindings");
|
|
56
|
-
}
|
|
57
|
-
} catch (err) {
|
|
58
|
-
console.error("Failed to save keybindings:", err);
|
|
59
|
-
toast.error("Failed to save keybindings");
|
|
60
|
-
}
|
|
61
|
-
},
|
|
62
|
-
[],
|
|
63
|
-
);
|
|
64
|
-
|
|
65
|
-
const updateBinding = useCallback(
|
|
66
|
-
async (id: string, binding: ShortcutBinding) => {
|
|
67
|
-
const newOverrides = overrides.filter((o) => o.id !== id);
|
|
68
|
-
newOverrides.push({ id, binding, enabled: true });
|
|
69
|
-
|
|
70
|
-
setOverrides(newOverrides);
|
|
71
|
-
await persistOverrides(newOverrides);
|
|
72
|
-
},
|
|
73
|
-
[overrides, persistOverrides],
|
|
74
|
-
);
|
|
75
|
-
|
|
76
|
-
const toggleEnabled = useCallback(
|
|
77
|
-
async (id: string) => {
|
|
78
|
-
const existing = overrides.find((o) => o.id === id);
|
|
79
|
-
const currentEnabled = existing?.enabled ?? true;
|
|
80
|
-
const newOverrides = overrides.filter((o) => o.id !== id);
|
|
81
|
-
newOverrides.push({
|
|
82
|
-
id,
|
|
83
|
-
binding: existing?.binding,
|
|
84
|
-
enabled: !currentEnabled,
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
setOverrides(newOverrides);
|
|
88
|
-
await persistOverrides(newOverrides);
|
|
89
|
-
},
|
|
90
|
-
[overrides, persistOverrides],
|
|
91
|
-
);
|
|
92
|
-
|
|
93
|
-
const resetToDefaults = useCallback(async () => {
|
|
94
|
-
setOverrides([]);
|
|
95
|
-
await persistOverrides([]);
|
|
96
|
-
toast.success("Keyboard shortcuts reset to defaults");
|
|
97
|
-
}, [persistOverrides]);
|
|
98
|
-
|
|
99
|
-
const shortcuts = resolveShortcuts(overrides);
|
|
100
|
-
|
|
101
|
-
return {
|
|
102
|
-
shortcuts,
|
|
103
|
-
updateBinding,
|
|
104
|
-
toggleEnabled,
|
|
105
|
-
resetToDefaults,
|
|
106
|
-
isLoading,
|
|
107
|
-
};
|
|
108
|
-
}
|