@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
|
@@ -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
|
|
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 () =>
|
|
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
|
|
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>
|