@peaske7/readit 0.1.7 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -3
- package/biome.json +1 -1
- package/bun.lock +43 -185
- package/docs/perf-baseline.md +75 -0
- package/docs/superpowers/plans/2026-03-26-surgical-pruning.md +1176 -0
- package/e2e/perf/add-comment.spec.ts +118 -0
- package/e2e/perf/fixtures/generate.ts +331 -0
- package/e2e/perf/initial-load.spec.ts +49 -0
- package/e2e/perf/perf.setup.ts +23 -0
- package/e2e/perf/perf.teardown.ts +9 -0
- package/e2e/perf/scroll.spec.ts +39 -0
- package/e2e/perf/tab-switch.spec.ts +69 -0
- package/e2e/perf/text-selection.spec.ts +119 -0
- package/e2e/perf/utils/metrics.ts +286 -0
- package/e2e/perf/utils/perf-cli.ts +86 -0
- package/package.json +9 -18
- package/playwright.config.ts +12 -0
- package/src/App.tsx +133 -178
- package/src/{cli/index.ts → cli.ts} +211 -107
- package/src/components/ActionsMenu.tsx +6 -27
- package/src/components/DocumentViewer/DocumentViewer.tsx +78 -105
- package/src/components/DocumentViewer/MermaidDiagram.tsx +6 -7
- package/src/components/Header.tsx +9 -20
- package/src/components/InlineEditor.tsx +5 -5
- package/src/components/MarginNote.tsx +71 -93
- package/src/components/MarginNotes.tsx +7 -34
- package/src/components/RawModal.tsx +9 -8
- package/src/components/ReanchorConfirm.tsx +2 -2
- package/src/components/SettingsModal.tsx +11 -89
- package/src/components/TabBar.tsx +4 -4
- package/src/components/TableOfContents.tsx +5 -5
- package/src/components/comments/CommentInput.tsx +7 -35
- package/src/components/comments/CommentListItem.tsx +9 -11
- package/src/components/comments/CommentManager.tsx +53 -37
- package/src/components/comments/CommentNav.tsx +14 -14
- package/src/components/ui/ActionLink.tsx +14 -18
- package/src/components/ui/Button.tsx +42 -43
- package/src/components/ui/Dialog.tsx +73 -113
- package/src/components/ui/DropdownMenu.tsx +113 -69
- package/src/components/ui/Text.tsx +30 -37
- package/src/contexts/CommentContext.tsx +75 -106
- package/src/contexts/LocaleContext.tsx +45 -4
- package/src/contexts/PositionsContext.tsx +16 -0
- package/src/contexts/SettingsContext.tsx +133 -0
- package/src/hooks/useClickOutside.ts +0 -4
- package/src/hooks/useCommentNavigation.ts +6 -29
- package/src/hooks/useComments.ts +6 -18
- package/src/hooks/useDocument.ts +35 -34
- package/src/hooks/useHeadings.test.ts +8 -50
- package/src/hooks/useHeadings.ts +5 -88
- package/src/hooks/useScrollSpy.ts +10 -14
- package/src/hooks/useTextSelection.ts +1 -38
- package/src/lib/__fixtures__/bench-data.ts +1 -41
- package/src/lib/anchor.bench.ts +57 -67
- package/src/lib/anchor.test.ts +5 -1
- package/src/lib/anchor.ts +13 -93
- package/src/lib/comment-storage.test.ts +4 -4
- package/src/lib/comment-storage.ts +2 -46
- package/src/lib/export.ts +7 -13
- package/src/lib/highlight/core.test.ts +1 -1
- package/src/lib/highlight/dom.ts +5 -68
- package/src/lib/highlight/highlighter.ts +102 -262
- package/src/lib/highlight/resolver.ts +112 -0
- package/src/lib/highlight/types.ts +0 -35
- package/src/lib/highlight/worker.ts +45 -0
- package/src/lib/i18n/en.ts +1 -50
- package/src/lib/i18n/ja.ts +1 -50
- package/src/lib/i18n/types.ts +1 -49
- package/src/lib/margin-layout.ts +5 -27
- package/src/lib/positions.ts +150 -0
- package/src/lib/utils.ts +2 -19
- package/src/schema.ts +81 -0
- package/src/{server/index.ts → server.ts} +111 -81
- package/src/{store/index.ts → store.ts} +14 -46
- package/vite.config.ts +8 -0
- package/src/components/DocumentViewer/IframeContainer.tsx +0 -251
- package/src/components/DocumentViewer/InlineCode.tsx +0 -60
- package/src/components/DocumentViewer/index.ts +0 -1
- package/src/components/FloatingTOC.tsx +0 -61
- package/src/components/ShortcutCapture.tsx +0 -48
- package/src/components/ShortcutList.tsx +0 -198
- package/src/components/comments/CommentMinimap.tsx +0 -62
- package/src/components/ui/ActionBar.tsx +0 -16
- package/src/components/ui/SeparatorDot.tsx +0 -9
- package/src/contexts/LayoutContext.tsx +0 -88
- package/src/hooks/useClipboard.ts +0 -82
- package/src/hooks/useEditorScheme.ts +0 -51
- package/src/hooks/useFontPreference.ts +0 -59
- package/src/hooks/useKeybindings.ts +0 -108
- package/src/hooks/useKeyboardShortcuts.ts +0 -63
- package/src/hooks/useLayoutMode.ts +0 -44
- package/src/hooks/useLocalePreference.ts +0 -42
- package/src/hooks/useReanchorMode.ts +0 -33
- package/src/hooks/useScrollMetrics.ts +0 -56
- package/src/hooks/useThemePreference.ts +0 -66
- package/src/lib/comment-storage.bench.ts +0 -63
- package/src/lib/context.bench.ts +0 -41
- package/src/lib/context.test.ts +0 -224
- package/src/lib/context.ts +0 -193
- package/src/lib/editor-links.ts +0 -59
- package/src/lib/export.bench.ts +0 -35
- package/src/lib/highlight/colors.ts +0 -37
- package/src/lib/highlight/core.ts +0 -54
- package/src/lib/highlight/index.ts +0 -23
- package/src/lib/highlight/script-builder.ts +0 -485
- package/src/lib/html-processor.test.tsx +0 -170
- package/src/lib/html-processor.tsx +0 -95
- package/src/lib/i18n/completeness.test.ts +0 -51
- package/src/lib/i18n/translations.test.ts +0 -39
- package/src/lib/layout-constants.ts +0 -12
- package/src/lib/margin-layout.bench.ts +0 -28
- package/src/lib/scroll.test.ts +0 -118
- package/src/lib/scroll.ts +0 -47
- package/src/lib/shortcut-registry.test.ts +0 -173
- package/src/lib/shortcut-registry.ts +0 -209
- package/src/lib/utils.test.ts +0 -110
- package/src/store/index.test.ts +0 -242
- package/src/types/index.ts +0 -127
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
|
});
|
package/src/hooks/useHeadings.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { useMemo } from "react";
|
|
2
2
|
import { slugify } from "../lib/utils";
|
|
3
|
-
import type { DocumentType } from "../types";
|
|
4
3
|
|
|
5
4
|
export interface Heading {
|
|
6
5
|
id: string;
|
|
@@ -8,29 +7,17 @@ export interface Heading {
|
|
|
8
7
|
level: 1 | 2 | 3 | 4 | 5 | 6;
|
|
9
8
|
}
|
|
10
9
|
|
|
11
|
-
/**
|
|
12
|
-
* Remove code blocks from markdown content.
|
|
13
|
-
* Handles both fenced (```) and indented (4 spaces) code blocks.
|
|
14
|
-
*/
|
|
15
10
|
function stripCodeBlocks(content: string): string {
|
|
16
|
-
// Remove fenced code blocks (``` or ~~~)
|
|
17
11
|
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)
|
|
12
|
+
// Only remove indented blocks preceded by a blank line (avoids removing list items)
|
|
21
13
|
result = result.replace(/(?:^|\n\n)((?:(?:[ ]{4}|\t).+\n?)+)/g, "\n\n");
|
|
22
14
|
|
|
23
15
|
return result;
|
|
24
16
|
}
|
|
25
17
|
|
|
26
|
-
/**
|
|
27
|
-
* Extract headings from markdown content
|
|
28
|
-
*/
|
|
29
18
|
function parseMarkdownHeadings(content: string): Heading[] {
|
|
30
19
|
const headings: Heading[] = [];
|
|
31
20
|
const seenIds = new Map<string, number>();
|
|
32
|
-
|
|
33
|
-
// Strip code blocks to avoid matching # comments in code
|
|
34
21
|
const contentWithoutCode = stripCodeBlocks(content);
|
|
35
22
|
|
|
36
23
|
const regex = /^(#{1,6})\s+(.+)$/gm;
|
|
@@ -51,79 +38,9 @@ function parseMarkdownHeadings(content: string): Heading[] {
|
|
|
51
38
|
return headings;
|
|
52
39
|
}
|
|
53
40
|
|
|
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[] {
|
|
41
|
+
export function useHeadings(content: string | null): Heading[] {
|
|
120
42
|
return useMemo(() => {
|
|
121
|
-
if (!content
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
return parseMarkdownHeadings(content);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
return parseHtmlHeadings(content);
|
|
128
|
-
}, [content, type]);
|
|
43
|
+
if (!content) return [];
|
|
44
|
+
return parseMarkdownHeadings(content);
|
|
45
|
+
}, [content]);
|
|
129
46
|
}
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from "react";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
export function useScrollSpy(headingIds: string[]): string | null {
|
|
3
|
+
export function useScrollSpy(
|
|
4
|
+
headingIds: string[],
|
|
5
|
+
enabled = true,
|
|
6
|
+
): string | null {
|
|
8
7
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
9
8
|
const hasSetInitialRef = useRef(false);
|
|
10
9
|
|
|
11
10
|
useEffect(() => {
|
|
12
|
-
if (headingIds.length === 0) {
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
if (!enabled || headingIds.length === 0) {
|
|
12
|
+
if (headingIds.length === 0) {
|
|
13
|
+
setActiveId(null);
|
|
14
|
+
hasSetInitialRef.current = false;
|
|
15
|
+
}
|
|
15
16
|
return;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
// Track visible headings and their positions
|
|
19
19
|
const visibleHeadings = new Map<string, number>();
|
|
20
20
|
|
|
21
21
|
const observer = new IntersectionObserver(
|
|
@@ -24,20 +24,17 @@ export function useScrollSpy(headingIds: string[]): string | null {
|
|
|
24
24
|
const id = entry.target.id;
|
|
25
25
|
|
|
26
26
|
if (entry.isIntersecting) {
|
|
27
|
-
// Store the top position when heading becomes visible
|
|
28
27
|
visibleHeadings.set(id, entry.boundingClientRect.top);
|
|
29
28
|
} else {
|
|
30
29
|
visibleHeadings.delete(id);
|
|
31
30
|
}
|
|
32
31
|
}
|
|
33
32
|
|
|
34
|
-
// Find the heading closest to the top of the viewport
|
|
35
33
|
if (visibleHeadings.size > 0) {
|
|
36
34
|
let closestId: string | null = null;
|
|
37
35
|
let closestDistance = Number.POSITIVE_INFINITY;
|
|
38
36
|
|
|
39
37
|
for (const [id, top] of visibleHeadings) {
|
|
40
|
-
// Prefer headings that are near the top but still visible
|
|
41
38
|
const distance = Math.abs(top);
|
|
42
39
|
if (distance < closestDistance) {
|
|
43
40
|
closestDistance = distance;
|
|
@@ -64,7 +61,6 @@ export function useScrollSpy(headingIds: string[]): string | null {
|
|
|
64
61
|
hasSetInitialRef.current = true;
|
|
65
62
|
}
|
|
66
63
|
|
|
67
|
-
// Observe all headings
|
|
68
64
|
for (const id of headingIds) {
|
|
69
65
|
const element = document.getElementById(id);
|
|
70
66
|
if (element) {
|
|
@@ -75,7 +71,7 @@ export function useScrollSpy(headingIds: string[]): string | null {
|
|
|
75
71
|
return () => {
|
|
76
72
|
observer.disconnect();
|
|
77
73
|
};
|
|
78
|
-
}, [headingIds]);
|
|
74
|
+
}, [headingIds, enabled]);
|
|
79
75
|
|
|
80
76
|
return activeId;
|
|
81
77
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect } from "react";
|
|
2
|
+
import type { Selection } from "../schema";
|
|
2
3
|
import { appStore, useAppStore } from "../store";
|
|
3
|
-
import type { Selection } from "../types";
|
|
4
4
|
|
|
5
5
|
/** Remove pending highlight marks from the DOM without triggering a full clear/reapply cycle. */
|
|
6
6
|
function clearPendingMarks() {
|
|
@@ -14,8 +14,6 @@ function clearPendingMarks() {
|
|
|
14
14
|
|
|
15
15
|
interface UseTextSelectionResult {
|
|
16
16
|
selection: Selection | null;
|
|
17
|
-
highlightPositions: Record<string, number>;
|
|
18
|
-
documentPositions: Record<string, number>;
|
|
19
17
|
pendingSelectionTop: number | undefined;
|
|
20
18
|
onTextSelect: (
|
|
21
19
|
text: string,
|
|
@@ -23,28 +21,13 @@ interface UseTextSelectionResult {
|
|
|
23
21
|
endOffset: number,
|
|
24
22
|
selectionTop: number,
|
|
25
23
|
) => void;
|
|
26
|
-
onPositionsChange: (
|
|
27
|
-
positions: Record<string, number>,
|
|
28
|
-
docPositions: Record<string, number>,
|
|
29
|
-
pendingTop?: number,
|
|
30
|
-
) => void;
|
|
31
24
|
clearSelection: () => void;
|
|
32
25
|
}
|
|
33
26
|
|
|
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
27
|
export function useTextSelection(): UseTextSelectionResult {
|
|
39
28
|
const selection = useAppStore(
|
|
40
29
|
(s) => s.getActiveDocumentState()?.selection ?? null,
|
|
41
30
|
);
|
|
42
|
-
const highlightPositions = useAppStore(
|
|
43
|
-
(s) => s.getActiveDocumentState()?.highlightPositions ?? {},
|
|
44
|
-
);
|
|
45
|
-
const documentPositions = useAppStore(
|
|
46
|
-
(s) => s.getActiveDocumentState()?.documentPositions ?? {},
|
|
47
|
-
);
|
|
48
31
|
const pendingSelectionTop = useAppStore(
|
|
49
32
|
(s) => s.getActiveDocumentState()?.pendingSelectionTop,
|
|
50
33
|
);
|
|
@@ -54,15 +37,10 @@ export function useTextSelection(): UseTextSelectionResult {
|
|
|
54
37
|
|
|
55
38
|
const handleClickOutside = (e: MouseEvent) => {
|
|
56
39
|
const target = e.target as HTMLElement;
|
|
57
|
-
|
|
58
|
-
// Don't clear if clicking inside the comment input area
|
|
59
40
|
if (target.closest("[data-comment-input]")) return;
|
|
60
|
-
|
|
61
|
-
// Don't clear if clicking on any highlight (pending or comment)
|
|
62
41
|
if (target.closest("mark[data-pending]")) return;
|
|
63
42
|
if (target.closest("mark[data-comment-id]")) return;
|
|
64
43
|
|
|
65
|
-
// Clear selection state and pending marks
|
|
66
44
|
appStore.getState().setSelection(null);
|
|
67
45
|
appStore.getState().setPendingSelectionTop(undefined);
|
|
68
46
|
clearPendingMarks();
|
|
@@ -92,18 +70,6 @@ export function useTextSelection(): UseTextSelectionResult {
|
|
|
92
70
|
[],
|
|
93
71
|
);
|
|
94
72
|
|
|
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
73
|
const clearSelection = useCallback(() => {
|
|
108
74
|
appStore.getState().setSelection(null);
|
|
109
75
|
appStore.getState().setPendingSelectionTop(undefined);
|
|
@@ -113,11 +79,8 @@ export function useTextSelection(): UseTextSelectionResult {
|
|
|
113
79
|
|
|
114
80
|
return {
|
|
115
81
|
selection,
|
|
116
|
-
highlightPositions,
|
|
117
|
-
documentPositions,
|
|
118
82
|
pendingSelectionTop,
|
|
119
83
|
onTextSelect,
|
|
120
|
-
onPositionsChange,
|
|
121
84
|
clearSelection,
|
|
122
85
|
};
|
|
123
86
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Comment, CommentFile } from "../../
|
|
1
|
+
import type { Comment, CommentFile } from "../../schema";
|
|
2
2
|
import { serializeComments } from "../comment-storage";
|
|
3
3
|
|
|
4
4
|
// --- Document fixtures ---
|
|
@@ -125,43 +125,3 @@ export const COMMENT_FILE_OBJ_LARGE = makeCommentFile(COMMENTS_50);
|
|
|
125
125
|
export const COMMENT_FILE_SMALL = serializeComments(COMMENT_FILE_OBJ_SMALL);
|
|
126
126
|
export const COMMENT_FILE_MEDIUM = serializeComments(COMMENT_FILE_OBJ_MEDIUM);
|
|
127
127
|
export const COMMENT_FILE_LARGE = serializeComments(COMMENT_FILE_OBJ_LARGE);
|
|
128
|
-
|
|
129
|
-
// --- Highlight position fixtures ---
|
|
130
|
-
|
|
131
|
-
export function makeHighlightPositions(count: number): Record<string, number> {
|
|
132
|
-
const positions: Record<string, number> = {};
|
|
133
|
-
for (let i = 0; i < count; i++) {
|
|
134
|
-
// ~200px spacing with clustering every 3rd note for overlap scenarios
|
|
135
|
-
positions[`c${i}`] = i * 200 + (i % 3 === 0 ? 50 : 0);
|
|
136
|
-
}
|
|
137
|
-
return positions;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// --- HTML fixture ---
|
|
141
|
-
|
|
142
|
-
function generateHtmlDoc(): string {
|
|
143
|
-
const parts: string[] = [
|
|
144
|
-
"<!DOCTYPE html>",
|
|
145
|
-
"<html><head>",
|
|
146
|
-
"<style>.main { color: red; font-size: 16px; }</style>",
|
|
147
|
-
"<script>console.log('test script');</script>",
|
|
148
|
-
"</head><body>",
|
|
149
|
-
];
|
|
150
|
-
|
|
151
|
-
for (let i = 0; i < 30; i++) {
|
|
152
|
-
parts.push(`<section id="s${i}">`);
|
|
153
|
-
parts.push(`<h2>Section ${i}</h2>`);
|
|
154
|
-
parts.push(
|
|
155
|
-
`<p>Paragraph with & entities <and> special "chars" in section ${i}.</p>`,
|
|
156
|
-
);
|
|
157
|
-
parts.push(
|
|
158
|
-
`<ul><li>Item ${i}.1</li><li>Item ${i}.2</li><li>Item ${i}.3</li></ul>`,
|
|
159
|
-
);
|
|
160
|
-
parts.push("</section>");
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
parts.push("</body></html>");
|
|
164
|
-
return parts.join("\n");
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
export const HTML_DOC = generateHtmlDoc();
|