@chrysb/alphaclaw 0.3.5-beta.0 → 0.3.5-beta.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.
Files changed (44) hide show
  1. package/bin/alphaclaw.js +65 -1
  2. package/lib/public/css/explorer.css +201 -6
  3. package/lib/public/js/app.js +45 -1
  4. package/lib/public/js/components/channels.js +1 -0
  5. package/lib/public/js/components/file-tree.js +56 -67
  6. package/lib/public/js/components/file-viewer/constants.js +6 -0
  7. package/lib/public/js/components/file-viewer/diff-viewer.js +46 -0
  8. package/lib/public/js/components/file-viewer/editor-surface.js +120 -0
  9. package/lib/public/js/components/file-viewer/frontmatter-panel.js +56 -0
  10. package/lib/public/js/components/file-viewer/index.js +164 -0
  11. package/lib/public/js/components/file-viewer/markdown-split-view.js +51 -0
  12. package/lib/public/js/components/file-viewer/media-preview.js +44 -0
  13. package/lib/public/js/components/file-viewer/scroll-sync.js +95 -0
  14. package/lib/public/js/components/file-viewer/sqlite-viewer.js +167 -0
  15. package/lib/public/js/components/file-viewer/status-banners.js +59 -0
  16. package/lib/public/js/components/file-viewer/storage.js +58 -0
  17. package/lib/public/js/components/file-viewer/toolbar.js +77 -0
  18. package/lib/public/js/components/file-viewer/use-editor-selection-restore.js +87 -0
  19. package/lib/public/js/components/file-viewer/use-file-diff.js +49 -0
  20. package/lib/public/js/components/file-viewer/use-file-loader.js +302 -0
  21. package/lib/public/js/components/file-viewer/use-file-viewer-draft-sync.js +32 -0
  22. package/lib/public/js/components/file-viewer/use-file-viewer-hotkeys.js +25 -0
  23. package/lib/public/js/components/file-viewer/use-file-viewer.js +379 -0
  24. package/lib/public/js/components/file-viewer/utils.js +11 -0
  25. package/lib/public/js/components/gateway.js +83 -30
  26. package/lib/public/js/components/icons.js +13 -0
  27. package/lib/public/js/components/sidebar-git-panel.js +72 -11
  28. package/lib/public/js/components/usage-tab.js +4 -1
  29. package/lib/public/js/components/watchdog-tab.js +6 -0
  30. package/lib/public/js/lib/api.js +16 -0
  31. package/lib/public/js/lib/browse-file-policies.js +34 -0
  32. package/lib/scripts/git +40 -0
  33. package/lib/scripts/git-askpass +6 -0
  34. package/lib/server/constants.js +8 -0
  35. package/lib/server/routes/browse/constants.js +51 -0
  36. package/lib/server/routes/browse/file-helpers.js +43 -0
  37. package/lib/server/routes/browse/git.js +131 -0
  38. package/lib/server/routes/{browse.js → browse/index.js} +290 -218
  39. package/lib/server/routes/browse/path-utils.js +53 -0
  40. package/lib/server/routes/browse/sqlite.js +140 -0
  41. package/lib/server/routes/proxy.js +11 -5
  42. package/lib/setup/core-prompts/TOOLS.md +0 -4
  43. package/package.json +1 -1
  44. package/lib/public/js/components/file-viewer.js +0 -1095
@@ -0,0 +1,6 @@
1
+ export const kFileViewerModeStorageKey = "alphaclaw.browse.fileViewerMode";
2
+ export const kLegacyFileViewerModeStorageKey = "alphaclawBrowseFileViewerMode";
3
+ export const kEditorSelectionStorageKey = "alphaclaw.browse.editorSelectionByPath";
4
+ export const kLoadingIndicatorDelayMs = 1000;
5
+ export const kFileRefreshIntervalMs = 5000;
6
+ export const kSqlitePageSize = 50;
@@ -0,0 +1,46 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+ import { LoadingSpinner } from "../loading-spinner.js";
4
+
5
+ const html = htm.bind(h);
6
+
7
+ const getDiffLineClass = (line) => {
8
+ if (line.startsWith("+") && !line.startsWith("+++")) return "is-added";
9
+ if (line.startsWith("-") && !line.startsWith("---")) return "is-removed";
10
+ if (line.startsWith("@@")) return "is-hunk";
11
+ if (
12
+ line.startsWith("diff ") ||
13
+ line.startsWith("index ") ||
14
+ line.startsWith("--- ") ||
15
+ line.startsWith("+++ ")
16
+ ) {
17
+ return "is-header";
18
+ }
19
+ return "";
20
+ };
21
+
22
+ export const DiffViewer = ({ diffLoading, diffError, diffContent }) => html`
23
+ <div class="file-viewer-diff-shell">
24
+ ${diffLoading
25
+ ? html`
26
+ <div class="file-viewer-loading-shell">
27
+ <${LoadingSpinner} className="h-4 w-4" />
28
+ </div>
29
+ `
30
+ : diffError
31
+ ? html`<div class="file-viewer-state file-viewer-state-error">${diffError}</div>`
32
+ : html`
33
+ <pre class="file-viewer-diff-pre">
34
+ ${(diffContent || "").split("\n").map((line, lineIndex) => html`
35
+ <div
36
+ key=${`${lineIndex}:${line.slice(0, 20)}`}
37
+ class=${`file-viewer-diff-line ${getDiffLineClass(line)}`.trim()}
38
+ >
39
+ ${line || " "}
40
+ </div>
41
+ `)}
42
+ </pre
43
+ >
44
+ `}
45
+ </div>
46
+ `;
@@ -0,0 +1,120 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+
4
+ const html = htm.bind(h);
5
+
6
+ const EditorTextarea = ({
7
+ overlay = false,
8
+ editorTextareaRef,
9
+ renderContent,
10
+ handleContentInput,
11
+ handleEditorScroll,
12
+ handleEditorSelectionChange,
13
+ isEditBlocked,
14
+ isPreviewOnly,
15
+ }) => html`
16
+ <textarea
17
+ class=${overlay ? "file-viewer-editor file-viewer-editor-overlay" : "file-viewer-editor"}
18
+ ref=${editorTextareaRef}
19
+ value=${renderContent}
20
+ onInput=${handleContentInput}
21
+ onScroll=${handleEditorScroll}
22
+ onSelect=${handleEditorSelectionChange}
23
+ onKeyUp=${handleEditorSelectionChange}
24
+ onClick=${handleEditorSelectionChange}
25
+ disabled=${isEditBlocked || isPreviewOnly}
26
+ readonly=${isEditBlocked || isPreviewOnly}
27
+ spellcheck=${false}
28
+ autocorrect="off"
29
+ autocapitalize="off"
30
+ autocomplete="off"
31
+ data-gramm="false"
32
+ data-gramm_editor="false"
33
+ data-enable-grammarly="false"
34
+ wrap="soft"
35
+ ></textarea>
36
+ `;
37
+
38
+ export const EditorSurface = ({
39
+ editorShellClassName = "file-viewer-editor-shell",
40
+ editorShellAriaHidden,
41
+ editorLineNumbers,
42
+ editorLineNumbersRef,
43
+ editorLineNumberRowRefs,
44
+ shouldUseHighlightedEditor,
45
+ highlightedEditorLines,
46
+ editorHighlightRef,
47
+ editorHighlightLineRefs,
48
+ editorTextareaRef,
49
+ renderContent,
50
+ handleContentInput,
51
+ handleEditorScroll,
52
+ handleEditorSelectionChange,
53
+ isEditBlocked,
54
+ isPreviewOnly,
55
+ }) => html`
56
+ <div class=${editorShellClassName} aria-hidden=${editorShellAriaHidden}>
57
+ <div class="file-viewer-editor-line-num-col" ref=${editorLineNumbersRef}>
58
+ ${editorLineNumbers.map(
59
+ (lineNumber) => html`
60
+ <div
61
+ class="file-viewer-editor-line-num"
62
+ key=${lineNumber}
63
+ ref=${(element) => {
64
+ editorLineNumberRowRefs.current[lineNumber - 1] = element;
65
+ }}
66
+ >
67
+ ${lineNumber}
68
+ </div>
69
+ `,
70
+ )}
71
+ </div>
72
+ ${shouldUseHighlightedEditor
73
+ ? html`
74
+ <div class="file-viewer-editor-stack">
75
+ <div class="file-viewer-editor-highlight" ref=${editorHighlightRef}>
76
+ ${highlightedEditorLines.map(
77
+ (line) => html`
78
+ <div
79
+ class="file-viewer-editor-highlight-line"
80
+ key=${line.lineNumber}
81
+ ref=${(element) => {
82
+ editorHighlightLineRefs.current[line.lineNumber - 1] = element;
83
+ }}
84
+ >
85
+ <span
86
+ class="file-viewer-editor-highlight-line-content"
87
+ dangerouslySetInnerHTML=${{
88
+ __html: line.html,
89
+ }}
90
+ ></span>
91
+ </div>
92
+ `,
93
+ )}
94
+ </div>
95
+ <${EditorTextarea}
96
+ overlay=${true}
97
+ editorTextareaRef=${editorTextareaRef}
98
+ renderContent=${renderContent}
99
+ handleContentInput=${handleContentInput}
100
+ handleEditorScroll=${handleEditorScroll}
101
+ handleEditorSelectionChange=${handleEditorSelectionChange}
102
+ isEditBlocked=${isEditBlocked}
103
+ isPreviewOnly=${isPreviewOnly}
104
+ />
105
+ </div>
106
+ `
107
+ : html`
108
+ <${EditorTextarea}
109
+ overlay=${false}
110
+ editorTextareaRef=${editorTextareaRef}
111
+ renderContent=${renderContent}
112
+ handleContentInput=${handleContentInput}
113
+ handleEditorScroll=${handleEditorScroll}
114
+ handleEditorSelectionChange=${handleEditorSelectionChange}
115
+ isEditBlocked=${isEditBlocked}
116
+ isPreviewOnly=${isPreviewOnly}
117
+ />
118
+ `}
119
+ </div>
120
+ `;
@@ -0,0 +1,56 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+ import { formatFrontmatterValue } from "../../lib/syntax-highlighters/index.js";
4
+
5
+ const html = htm.bind(h);
6
+
7
+ export const FrontmatterPanel = ({
8
+ isMarkdownFile,
9
+ parsedFrontmatter,
10
+ frontmatterCollapsed,
11
+ setFrontmatterCollapsed,
12
+ }) => {
13
+ if (!isMarkdownFile || parsedFrontmatter.entries.length <= 0) return null;
14
+
15
+ return html`
16
+ <div class="frontmatter-box">
17
+ <button
18
+ type="button"
19
+ class="frontmatter-title"
20
+ onclick=${() => setFrontmatterCollapsed((collapsed) => !collapsed)}
21
+ >
22
+ <span
23
+ class=${`frontmatter-chevron ${frontmatterCollapsed ? "" : "open"}`}
24
+ aria-hidden="true"
25
+ >
26
+ <svg viewBox="0 0 20 20" focusable="false">
27
+ <path d="M7 4l6 6-6 6" />
28
+ </svg>
29
+ </span>
30
+ <span>frontmatter</span>
31
+ </button>
32
+ ${!frontmatterCollapsed
33
+ ? html`
34
+ <div class="frontmatter-grid">
35
+ ${parsedFrontmatter.entries.map((entry) => {
36
+ const formattedValue = formatFrontmatterValue(entry.rawValue);
37
+ const isMultilineValue = formattedValue.includes("\n");
38
+ return html`
39
+ <div class="frontmatter-row" key=${entry.key}>
40
+ <div class="frontmatter-key">${entry.key}</div>
41
+ ${isMultilineValue
42
+ ? html`
43
+ <pre class="frontmatter-value frontmatter-value-pre">
44
+ ${formattedValue}</pre
45
+ >
46
+ `
47
+ : html`<div class="frontmatter-value">${formattedValue}</div>`}
48
+ </div>
49
+ `;
50
+ })}
51
+ </div>
52
+ `
53
+ : null}
54
+ </div>
55
+ `;
56
+ };
@@ -0,0 +1,164 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+ import { LoadingSpinner } from "../loading-spinner.js";
4
+ import { SqliteViewer } from "./sqlite-viewer.js";
5
+ import { FileViewerToolbar } from "./toolbar.js";
6
+ import { FileViewerStatusBanners } from "./status-banners.js";
7
+ import { FrontmatterPanel } from "./frontmatter-panel.js";
8
+ import { DiffViewer } from "./diff-viewer.js";
9
+ import { MediaPreview } from "./media-preview.js";
10
+ import { EditorSurface } from "./editor-surface.js";
11
+ import { MarkdownSplitView } from "./markdown-split-view.js";
12
+ import { kSqlitePageSize } from "./constants.js";
13
+ import { useFileViewer } from "./use-file-viewer.js";
14
+
15
+ const html = htm.bind(h);
16
+
17
+ export const FileViewer = ({
18
+ filePath = "",
19
+ isPreviewOnly = false,
20
+ browseView = "edit",
21
+ onRequestEdit = () => {},
22
+ }) => {
23
+ const { state, derived, refs, actions, context } = useFileViewer({
24
+ filePath,
25
+ isPreviewOnly,
26
+ browseView,
27
+ });
28
+
29
+ if (!state.hasSelectedPath) {
30
+ return html`
31
+ <div class="file-viewer-empty">
32
+ <div class="file-viewer-empty-mark">[ ]</div>
33
+ <div class="file-viewer-empty-title">Browse and edit files<br />Syncs to git</div>
34
+ </div>
35
+ `;
36
+ }
37
+
38
+ return html`
39
+ <div class="file-viewer">
40
+ <${FileViewerToolbar}
41
+ pathSegments=${derived.pathSegments}
42
+ isDirty=${derived.isDirty}
43
+ isPreviewOnly=${state.isPreviewOnly}
44
+ isDiffView=${state.isDiffView}
45
+ isMarkdownFile=${state.isMarkdownFile}
46
+ viewMode=${state.viewMode}
47
+ handleChangeViewMode=${actions.handleChangeViewMode}
48
+ handleSave=${actions.handleSave}
49
+ loading=${state.loading}
50
+ canEditFile=${derived.canEditFile}
51
+ isEditBlocked=${derived.isEditBlocked}
52
+ isImageFile=${state.isImageFile}
53
+ isAudioFile=${state.isAudioFile}
54
+ isSqliteFile=${state.isSqliteFile}
55
+ saving=${state.saving}
56
+ />
57
+ <${FileViewerStatusBanners}
58
+ isDiffView=${state.isDiffView}
59
+ onRequestEdit=${onRequestEdit}
60
+ normalizedPath=${context.normalizedPath}
61
+ isLockedFile=${derived.isLockedFile}
62
+ isProtectedFile=${derived.isProtectedFile}
63
+ isProtectedLocked=${derived.isProtectedLocked}
64
+ handleEditProtectedFile=${actions.handleEditProtectedFile}
65
+ />
66
+ <${FrontmatterPanel}
67
+ isMarkdownFile=${state.isMarkdownFile}
68
+ parsedFrontmatter=${derived.parsedFrontmatter}
69
+ frontmatterCollapsed=${state.frontmatterCollapsed}
70
+ setFrontmatterCollapsed=${actions.setFrontmatterCollapsed}
71
+ />
72
+ ${state.loading
73
+ ? html`
74
+ <div class="file-viewer-loading-shell">
75
+ ${state.showDelayedLoadingSpinner
76
+ ? html`<${LoadingSpinner} className="h-4 w-4" />`
77
+ : null}
78
+ </div>
79
+ `
80
+ : state.error
81
+ ? html`<div class="file-viewer-state file-viewer-state-error">${state.error}</div>`
82
+ : state.isFolderPath
83
+ ? html`
84
+ <div class="file-viewer-state">
85
+ Folder selected. Choose a file from this folder in the tree.
86
+ </div>
87
+ `
88
+ : state.isImageFile || state.isAudioFile
89
+ ? html`
90
+ <${MediaPreview}
91
+ isImageFile=${state.isImageFile}
92
+ imageDataUrl=${state.imageDataUrl}
93
+ pathSegments=${derived.pathSegments}
94
+ isAudioFile=${state.isAudioFile}
95
+ audioDataUrl=${state.audioDataUrl}
96
+ />
97
+ `
98
+ : state.isSqliteFile
99
+ ? html`
100
+ <${SqliteViewer}
101
+ sqliteSummary=${state.sqliteSummary}
102
+ sqliteSelectedTable=${state.sqliteSelectedTable}
103
+ setSqliteSelectedTable=${actions.setSqliteSelectedTable}
104
+ sqliteTableOffset=${state.sqliteTableOffset}
105
+ setSqliteTableOffset=${actions.setSqliteTableOffset}
106
+ sqliteTableLoading=${state.sqliteTableLoading}
107
+ sqliteTableError=${state.sqliteTableError}
108
+ sqliteTableData=${state.sqliteTableData}
109
+ kSqlitePageSize=${kSqlitePageSize}
110
+ />
111
+ `
112
+ : state.isDiffView
113
+ ? html`
114
+ <${DiffViewer}
115
+ diffLoading=${state.diffLoading}
116
+ diffError=${state.diffError}
117
+ diffContent=${state.diffContent}
118
+ />
119
+ `
120
+ : html`
121
+ ${state.isMarkdownFile
122
+ ? html`
123
+ <${MarkdownSplitView}
124
+ viewMode=${state.viewMode}
125
+ previewRef=${refs.previewRef}
126
+ handlePreviewScroll=${actions.handlePreviewScroll}
127
+ previewHtml=${state.previewHtml}
128
+ editorLineNumbers=${derived.editorLineNumbers}
129
+ editorLineNumbersRef=${refs.editorLineNumbersRef}
130
+ editorLineNumberRowRefs=${refs.editorLineNumberRowRefs}
131
+ highlightedEditorLines=${derived.highlightedEditorLines}
132
+ editorHighlightRef=${refs.editorHighlightRef}
133
+ editorHighlightLineRefs=${refs.editorHighlightLineRefs}
134
+ editorTextareaRef=${refs.editorTextareaRef}
135
+ renderContent=${state.renderContent}
136
+ handleContentInput=${actions.handleContentInput}
137
+ handleEditorScroll=${actions.handleEditorScroll}
138
+ handleEditorSelectionChange=${actions.handleEditorSelectionChange}
139
+ isEditBlocked=${derived.isEditBlocked}
140
+ isPreviewOnly=${state.isPreviewOnly}
141
+ />
142
+ `
143
+ : html`
144
+ <${EditorSurface}
145
+ editorLineNumbers=${derived.editorLineNumbers}
146
+ editorLineNumbersRef=${refs.editorLineNumbersRef}
147
+ editorLineNumberRowRefs=${refs.editorLineNumberRowRefs}
148
+ shouldUseHighlightedEditor=${derived.shouldUseHighlightedEditor}
149
+ highlightedEditorLines=${derived.highlightedEditorLines}
150
+ editorHighlightRef=${refs.editorHighlightRef}
151
+ editorHighlightLineRefs=${refs.editorHighlightLineRefs}
152
+ editorTextareaRef=${refs.editorTextareaRef}
153
+ renderContent=${state.renderContent}
154
+ handleContentInput=${actions.handleContentInput}
155
+ handleEditorScroll=${actions.handleEditorScroll}
156
+ handleEditorSelectionChange=${actions.handleEditorSelectionChange}
157
+ isEditBlocked=${derived.isEditBlocked}
158
+ isPreviewOnly=${state.isPreviewOnly}
159
+ />
160
+ `}
161
+ `}
162
+ </div>
163
+ `;
164
+ };
@@ -0,0 +1,51 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+ import { EditorSurface } from "./editor-surface.js";
4
+
5
+ const html = htm.bind(h);
6
+
7
+ export const MarkdownSplitView = ({
8
+ viewMode,
9
+ previewRef,
10
+ handlePreviewScroll,
11
+ previewHtml,
12
+ editorLineNumbers,
13
+ editorLineNumbersRef,
14
+ editorLineNumberRowRefs,
15
+ highlightedEditorLines,
16
+ editorHighlightRef,
17
+ editorHighlightLineRefs,
18
+ editorTextareaRef,
19
+ renderContent,
20
+ handleContentInput,
21
+ handleEditorScroll,
22
+ handleEditorSelectionChange,
23
+ isEditBlocked,
24
+ isPreviewOnly,
25
+ }) => html`
26
+ <div
27
+ class=${`file-viewer-preview ${viewMode === "preview" ? "" : "file-viewer-pane-hidden"}`}
28
+ ref=${previewRef}
29
+ onscroll=${handlePreviewScroll}
30
+ aria-hidden=${viewMode === "preview" ? "false" : "true"}
31
+ dangerouslySetInnerHTML=${{ __html: previewHtml }}
32
+ ></div>
33
+ <${EditorSurface}
34
+ editorShellClassName=${`file-viewer-editor-shell ${viewMode === "edit" ? "" : "file-viewer-pane-hidden"}`}
35
+ editorShellAriaHidden=${viewMode === "edit" ? "false" : "true"}
36
+ editorLineNumbers=${editorLineNumbers}
37
+ editorLineNumbersRef=${editorLineNumbersRef}
38
+ editorLineNumberRowRefs=${editorLineNumberRowRefs}
39
+ shouldUseHighlightedEditor=${true}
40
+ highlightedEditorLines=${highlightedEditorLines}
41
+ editorHighlightRef=${editorHighlightRef}
42
+ editorHighlightLineRefs=${editorHighlightLineRefs}
43
+ editorTextareaRef=${editorTextareaRef}
44
+ renderContent=${renderContent}
45
+ handleContentInput=${handleContentInput}
46
+ handleEditorScroll=${handleEditorScroll}
47
+ handleEditorSelectionChange=${handleEditorSelectionChange}
48
+ isEditBlocked=${isEditBlocked}
49
+ isPreviewOnly=${isPreviewOnly}
50
+ />
51
+ `;
@@ -0,0 +1,44 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+
4
+ const html = htm.bind(h);
5
+
6
+ export const MediaPreview = ({
7
+ isImageFile,
8
+ imageDataUrl,
9
+ pathSegments,
10
+ isAudioFile,
11
+ audioDataUrl,
12
+ }) => {
13
+ if (isImageFile) {
14
+ return html`
15
+ <div class="file-viewer-image-shell">
16
+ ${imageDataUrl
17
+ ? html`
18
+ <img
19
+ src=${imageDataUrl}
20
+ alt=${pathSegments[pathSegments.length - 1] || "Selected image"}
21
+ class="file-viewer-image"
22
+ />
23
+ `
24
+ : html`<div class="file-viewer-state">Could not render image preview.</div>`}
25
+ </div>
26
+ `;
27
+ }
28
+
29
+ if (isAudioFile) {
30
+ return html`
31
+ <div class="file-viewer-audio-shell">
32
+ ${audioDataUrl
33
+ ? html`
34
+ <audio class="file-viewer-audio-player" controls preload="metadata" src=${audioDataUrl}>
35
+ Your browser does not support audio playback.
36
+ </audio>
37
+ `
38
+ : html`<div class="file-viewer-state">Could not render audio preview.</div>`}
39
+ </div>
40
+ `;
41
+ }
42
+
43
+ return null;
44
+ };
@@ -0,0 +1,95 @@
1
+ import { useRef } from "https://esm.sh/preact/hooks";
2
+
3
+ export const getScrollRatio = (element) => {
4
+ if (!element) return 0;
5
+ const maxScrollTop = element.scrollHeight - element.clientHeight;
6
+ if (maxScrollTop <= 0) return 0;
7
+ return element.scrollTop / maxScrollTop;
8
+ };
9
+
10
+ export const setScrollByRatio = (element, ratio) => {
11
+ if (!element) return;
12
+ const maxScrollTop = element.scrollHeight - element.clientHeight;
13
+ if (maxScrollTop <= 0) {
14
+ element.scrollTop = 0;
15
+ return;
16
+ }
17
+ const clampedRatio = Math.max(0, Math.min(1, ratio));
18
+ element.scrollTop = maxScrollTop * clampedRatio;
19
+ };
20
+
21
+ export const useScrollSync = ({
22
+ viewMode,
23
+ setViewMode,
24
+ previewRef,
25
+ editorTextareaRef,
26
+ editorLineNumbersRef,
27
+ editorHighlightRef,
28
+ }) => {
29
+ const viewScrollRatioRef = useRef(0);
30
+ const isSyncingScrollRef = useRef(false);
31
+
32
+ const handleEditorScroll = (event) => {
33
+ if (isSyncingScrollRef.current) return;
34
+ const nextScrollTop = event.currentTarget.scrollTop;
35
+ const nextRatio = getScrollRatio(event.currentTarget);
36
+ viewScrollRatioRef.current = nextRatio;
37
+ if (!editorLineNumbersRef.current) return;
38
+ editorLineNumbersRef.current.scrollTop = nextScrollTop;
39
+ if (editorHighlightRef.current) {
40
+ editorHighlightRef.current.scrollTop = nextScrollTop;
41
+ editorHighlightRef.current.scrollLeft = event.currentTarget.scrollLeft;
42
+ }
43
+ if (previewRef.current) {
44
+ isSyncingScrollRef.current = true;
45
+ setScrollByRatio(previewRef.current, nextRatio);
46
+ window.requestAnimationFrame(() => {
47
+ isSyncingScrollRef.current = false;
48
+ });
49
+ }
50
+ };
51
+
52
+ const handlePreviewScroll = (event) => {
53
+ if (isSyncingScrollRef.current) return;
54
+ const nextRatio = getScrollRatio(event.currentTarget);
55
+ viewScrollRatioRef.current = nextRatio;
56
+ isSyncingScrollRef.current = true;
57
+ setScrollByRatio(editorTextareaRef.current, nextRatio);
58
+ setScrollByRatio(editorLineNumbersRef.current, nextRatio);
59
+ setScrollByRatio(editorHighlightRef.current, nextRatio);
60
+ window.requestAnimationFrame(() => {
61
+ isSyncingScrollRef.current = false;
62
+ });
63
+ };
64
+
65
+ const handleChangeViewMode = (nextMode) => {
66
+ if (nextMode === viewMode) return;
67
+ const nextRatio =
68
+ viewMode === "preview"
69
+ ? getScrollRatio(previewRef.current)
70
+ : getScrollRatio(editorTextareaRef.current);
71
+ viewScrollRatioRef.current = nextRatio;
72
+ setViewMode(nextMode);
73
+ window.requestAnimationFrame(() => {
74
+ isSyncingScrollRef.current = true;
75
+ if (nextMode === "preview") {
76
+ setScrollByRatio(previewRef.current, nextRatio);
77
+ } else {
78
+ setScrollByRatio(editorTextareaRef.current, nextRatio);
79
+ setScrollByRatio(editorLineNumbersRef.current, nextRatio);
80
+ setScrollByRatio(editorHighlightRef.current, nextRatio);
81
+ }
82
+ window.requestAnimationFrame(() => {
83
+ isSyncingScrollRef.current = false;
84
+ });
85
+ });
86
+ };
87
+
88
+ return {
89
+ viewScrollRatioRef,
90
+ isSyncingScrollRef,
91
+ handleEditorScroll,
92
+ handlePreviewScroll,
93
+ handleChangeViewMode,
94
+ };
95
+ };