@gmickel/gno 0.9.0 → 0.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
5
5
  "keywords": [
6
6
  "embeddings",
@@ -21,6 +21,8 @@ export interface CodeMirrorEditorProps {
21
21
  initialContent: string;
22
22
  /** Called when content changes */
23
23
  onChange: (content: string) => void;
24
+ /** Called when scroll position changes (0-1 percentage) */
25
+ onScroll?: (scrollPercent: number) => void;
24
26
  /** Additional CSS classes */
25
27
  className?: string;
26
28
  }
@@ -36,21 +38,28 @@ export interface CodeMirrorEditorRef {
36
38
  wrapSelection: (prefix: string, suffix: string) => void;
37
39
  /** Insert text at cursor position */
38
40
  insertAtCursor: (text: string) => void;
41
+ /** Scroll to percentage position (0-1). Returns true if scroll actually changed. */
42
+ scrollToPercent: (percent: number) => boolean;
39
43
  }
40
44
 
41
45
  function CodeMirrorEditorInner(
42
- { initialContent, onChange, className }: CodeMirrorEditorProps,
46
+ { initialContent, onChange, onScroll, className }: CodeMirrorEditorProps,
43
47
  ref: ForwardedRef<CodeMirrorEditorRef>
44
48
  ) {
45
49
  const containerRef = useRef<HTMLDivElement>(null);
46
50
  const viewRef = useRef<EditorView | null>(null);
47
51
  const onChangeRef = useRef(onChange);
52
+ const onScrollRef = useRef(onScroll);
48
53
 
49
- // Keep onChange ref current to avoid recreating editor on callback change
54
+ // Keep callback refs current to avoid recreating editor
50
55
  useEffect(() => {
51
56
  onChangeRef.current = onChange;
52
57
  }, [onChange]);
53
58
 
59
+ useEffect(() => {
60
+ onScrollRef.current = onScroll;
61
+ }, [onScroll]);
62
+
54
63
  // Initialize CodeMirror
55
64
  useEffect(() => {
56
65
  if (!containerRef.current) return;
@@ -81,8 +90,28 @@ function CodeMirrorEditorInner(
81
90
  parent: containerRef.current,
82
91
  });
83
92
 
93
+ // Attach scroll listener directly to scrollDOM for reliable event capture
94
+ // (scroll events don't bubble, so domEventHandlers may miss them)
95
+ const scroller = view.scrollDOM;
96
+ const handleScroll = () => {
97
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
98
+ if (maxScroll > 0 && onScrollRef.current) {
99
+ const percent = Math.max(
100
+ 0,
101
+ Math.min(1, scroller.scrollTop / maxScroll)
102
+ );
103
+ if (Number.isFinite(percent)) {
104
+ onScrollRef.current(percent);
105
+ }
106
+ }
107
+ };
108
+ scroller.addEventListener("scroll", handleScroll, { passive: true });
109
+
84
110
  viewRef.current = view;
85
- return () => view.destroy();
111
+ return () => {
112
+ scroller.removeEventListener("scroll", handleScroll);
113
+ view.destroy();
114
+ };
86
115
  // Only run on mount - initialContent should not trigger re-creation
87
116
  // eslint-disable-next-line react-hooks/exhaustive-deps
88
117
  }, []);
@@ -134,6 +163,23 @@ function CodeMirrorEditorInner(
134
163
  });
135
164
  view.focus();
136
165
  },
166
+ scrollToPercent: (percent: number): boolean => {
167
+ const view = viewRef.current;
168
+ if (!view || !Number.isFinite(percent)) return false;
169
+
170
+ const clamped = Math.max(0, Math.min(1, percent));
171
+ const scroller = view.scrollDOM;
172
+ const maxScroll = scroller.scrollHeight - scroller.clientHeight;
173
+ if (maxScroll > 0) {
174
+ const targetScroll = clamped * maxScroll;
175
+ // Only scroll if position actually changes (avoids unnecessary events)
176
+ if (Math.abs(scroller.scrollTop - targetScroll) > 0.5) {
177
+ scroller.scrollTop = targetScroll;
178
+ return true;
179
+ }
180
+ }
181
+ return false;
182
+ },
137
183
  }));
138
184
 
139
185
  return <div ref={containerRef} className={className} />;
@@ -16,8 +16,10 @@ import {
16
16
  CloudIcon,
17
17
  EyeIcon,
18
18
  EyeOffIcon,
19
+ LinkIcon,
19
20
  Loader2Icon,
20
21
  PenIcon,
22
+ UnlinkIcon,
21
23
  } from "lucide-react";
22
24
  import { useCallback, useEffect, useRef, useState } from "react";
23
25
 
@@ -131,11 +133,81 @@ export default function DocumentEditor({ navigate }: PageProps) {
131
133
  const [saveError, setSaveError] = useState<string | null>(null);
132
134
 
133
135
  const [showPreview, setShowPreview] = useState(true);
136
+ const [syncScroll, setSyncScroll] = useState(true);
134
137
  const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
135
138
  const editorRef = useRef<CodeMirrorEditorRef>(null);
139
+ const previewRef = useRef<HTMLDivElement>(null);
140
+ // Event-based suppression: ignore the echo event caused by programmatic scroll
141
+ const ignoreNextEditorScroll = useRef(false);
142
+ const ignoreNextPreviewScroll = useRef(false);
136
143
 
137
144
  const hasUnsavedChanges = content !== originalContent;
138
145
 
146
+ // Reset ignore flags when sync is toggled to prevent stale state
147
+ useEffect(() => {
148
+ ignoreNextEditorScroll.current = false;
149
+ ignoreNextPreviewScroll.current = false;
150
+ }, [syncScroll, showPreview]);
151
+
152
+ // Scroll sync handlers with event-based loop prevention
153
+ // Note: Uses percentage-based mapping which provides approximate correspondence.
154
+ // For very different layouts (headings, code blocks, images), perfect alignment
155
+ // would require anchor-based mapping between editor lines and rendered elements.
156
+ const handleEditorScroll = useCallback(
157
+ (scrollPercent: number) => {
158
+ // Clear ignore flag first, even if we early-return (prevents lingering)
159
+ if (ignoreNextEditorScroll.current) {
160
+ ignoreNextEditorScroll.current = false;
161
+ return;
162
+ }
163
+ if (!syncScroll || !showPreview) return;
164
+ if (!Number.isFinite(scrollPercent)) return;
165
+
166
+ const clamped = Math.max(0, Math.min(1, scrollPercent));
167
+ const preview = previewRef.current;
168
+ if (!preview) return;
169
+
170
+ const maxScroll = preview.scrollHeight - preview.clientHeight;
171
+ if (maxScroll <= 0) return;
172
+
173
+ const targetScroll = clamped * maxScroll;
174
+ // Only set ignore flag if scroll position actually changes (avoids lingering flag)
175
+ if (Math.abs(preview.scrollTop - targetScroll) > 0.5) {
176
+ ignoreNextPreviewScroll.current = true;
177
+ preview.scrollTop = targetScroll;
178
+ }
179
+ },
180
+ [syncScroll, showPreview]
181
+ );
182
+
183
+ const handlePreviewScroll = useCallback(() => {
184
+ // Clear ignore flag first, even if we early-return (prevents lingering)
185
+ if (ignoreNextPreviewScroll.current) {
186
+ ignoreNextPreviewScroll.current = false;
187
+ return;
188
+ }
189
+ if (!syncScroll) return;
190
+
191
+ const preview = previewRef.current;
192
+ if (!preview) return;
193
+
194
+ const maxScroll = preview.scrollHeight - preview.clientHeight;
195
+ if (maxScroll <= 0) return;
196
+
197
+ const scrollPercentRaw = preview.scrollTop / maxScroll;
198
+ if (!Number.isFinite(scrollPercentRaw)) return;
199
+ const scrollPercent = Math.max(0, Math.min(1, scrollPercentRaw));
200
+
201
+ // Set ignore flag BEFORE programmatic scroll to prevent race condition
202
+ ignoreNextEditorScroll.current = true;
203
+ const didScroll =
204
+ editorRef.current?.scrollToPercent(scrollPercent) ?? false;
205
+ // Clear flag if no scroll actually occurred (avoids lingering)
206
+ if (!didScroll) {
207
+ ignoreNextEditorScroll.current = false;
208
+ }
209
+ }, [syncScroll]);
210
+
139
211
  // Save function
140
212
  const saveDocument = useCallback(
141
213
  async (contentToSave: string) => {
@@ -442,6 +514,36 @@ export default function DocumentEditor({ navigate }: PageProps) {
442
514
  </Tooltip>
443
515
  </TooltipProvider>
444
516
 
517
+ {/* Sync scroll toggle (only show when preview is visible) */}
518
+ {showPreview && (
519
+ <TooltipProvider>
520
+ <Tooltip>
521
+ <TooltipTrigger asChild>
522
+ <Button
523
+ aria-label={
524
+ syncScroll ? "Disable scroll sync" : "Enable scroll sync"
525
+ }
526
+ className="transition-all duration-200"
527
+ onClick={() => setSyncScroll(!syncScroll)}
528
+ size="sm"
529
+ variant={syncScroll ? "secondary" : "ghost"}
530
+ >
531
+ {syncScroll ? (
532
+ <LinkIcon className="size-4" />
533
+ ) : (
534
+ <UnlinkIcon className="size-4" />
535
+ )}
536
+ </Button>
537
+ </TooltipTrigger>
538
+ <TooltipContent>
539
+ <p>
540
+ {syncScroll ? "Disable scroll sync" : "Enable scroll sync"}
541
+ </p>
542
+ </TooltipContent>
543
+ </Tooltip>
544
+ </TooltipProvider>
545
+ )}
546
+
445
547
  {/* Save button */}
446
548
  <Button
447
549
  disabled={!hasUnsavedChanges || saveStatus === "saving"}
@@ -468,13 +570,20 @@ export default function DocumentEditor({ navigate }: PageProps) {
468
570
  className="h-full"
469
571
  initialContent={content}
470
572
  onChange={handleContentChange}
573
+ onScroll={
574
+ syncScroll && showPreview ? handleEditorScroll : undefined
575
+ }
471
576
  ref={editorRef}
472
577
  />
473
578
  </div>
474
579
 
475
580
  {/* Preview pane */}
476
581
  {showPreview && (
477
- <div className="w-1/2 min-h-0 overflow-auto bg-background p-6">
582
+ <div
583
+ className="w-1/2 min-h-0 overflow-auto bg-background p-6"
584
+ onScroll={handlePreviewScroll}
585
+ ref={previewRef}
586
+ >
478
587
  <div className="mx-auto max-w-3xl">
479
588
  <MarkdownPreview content={content} />
480
589
  </div>