@chrysb/alphaclaw 0.3.5-beta.0 → 0.4.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.
Files changed (64) hide show
  1. package/bin/alphaclaw.js +66 -32
  2. package/lib/public/assets/icons/google_icon.svg +8 -0
  3. package/lib/public/css/explorer.css +254 -6
  4. package/lib/public/js/app.js +165 -100
  5. package/lib/public/js/components/channels.js +1 -0
  6. package/lib/public/js/components/credentials-modal.js +36 -8
  7. package/lib/public/js/components/file-tree.js +267 -88
  8. package/lib/public/js/components/file-viewer/constants.js +6 -0
  9. package/lib/public/js/components/file-viewer/diff-viewer.js +46 -0
  10. package/lib/public/js/components/file-viewer/editor-surface.js +120 -0
  11. package/lib/public/js/components/file-viewer/frontmatter-panel.js +56 -0
  12. package/lib/public/js/components/file-viewer/index.js +202 -0
  13. package/lib/public/js/components/file-viewer/markdown-split-view.js +51 -0
  14. package/lib/public/js/components/file-viewer/media-preview.js +44 -0
  15. package/lib/public/js/components/file-viewer/scroll-sync.js +95 -0
  16. package/lib/public/js/components/file-viewer/sqlite-viewer.js +167 -0
  17. package/lib/public/js/components/file-viewer/status-banners.js +64 -0
  18. package/lib/public/js/components/file-viewer/storage.js +58 -0
  19. package/lib/public/js/components/file-viewer/toolbar.js +119 -0
  20. package/lib/public/js/components/file-viewer/use-editor-selection-restore.js +93 -0
  21. package/lib/public/js/components/file-viewer/use-file-diff.js +60 -0
  22. package/lib/public/js/components/file-viewer/use-file-loader.js +312 -0
  23. package/lib/public/js/components/file-viewer/use-file-viewer-draft-sync.js +32 -0
  24. package/lib/public/js/components/file-viewer/use-file-viewer-hotkeys.js +25 -0
  25. package/lib/public/js/components/file-viewer/use-file-viewer.js +471 -0
  26. package/lib/public/js/components/file-viewer/utils.js +11 -0
  27. package/lib/public/js/components/gateway.js +83 -30
  28. package/lib/public/js/components/google/account-row.js +98 -0
  29. package/lib/public/js/components/google/add-account-modal.js +93 -0
  30. package/lib/public/js/components/google/index.js +439 -0
  31. package/lib/public/js/components/google/use-google-accounts.js +41 -0
  32. package/lib/public/js/components/icons.js +39 -0
  33. package/lib/public/js/components/sidebar-git-panel.js +115 -25
  34. package/lib/public/js/components/sidebar.js +91 -75
  35. package/lib/public/js/components/usage-tab.js +4 -1
  36. package/lib/public/js/components/watchdog-tab.js +6 -0
  37. package/lib/public/js/lib/api.js +88 -8
  38. package/lib/public/js/lib/browse-file-policies.js +52 -0
  39. package/lib/public/js/lib/syntax-highlighters/index.js +6 -5
  40. package/lib/public/shared/browse-file-policies.json +13 -0
  41. package/lib/scripts/git +40 -0
  42. package/lib/scripts/git-askpass +6 -0
  43. package/lib/server/constants.js +20 -0
  44. package/lib/server/google-state.js +187 -0
  45. package/lib/server/helpers.js +12 -4
  46. package/lib/server/onboarding/github.js +21 -2
  47. package/lib/server/onboarding/index.js +1 -3
  48. package/lib/server/onboarding/openclaw.js +3 -0
  49. package/lib/server/onboarding/workspace.js +40 -0
  50. package/lib/server/routes/browse/constants.js +51 -0
  51. package/lib/server/routes/browse/file-helpers.js +43 -0
  52. package/lib/server/routes/browse/git.js +131 -0
  53. package/lib/server/routes/browse/index.js +660 -0
  54. package/lib/server/routes/browse/path-utils.js +53 -0
  55. package/lib/server/routes/browse/sqlite.js +140 -0
  56. package/lib/server/routes/google.js +414 -213
  57. package/lib/server/routes/proxy.js +11 -5
  58. package/lib/setup/core-prompts/TOOLS.md +0 -4
  59. package/lib/setup/gitignore +3 -0
  60. package/lib/setup/hourly-git-sync.sh +28 -1
  61. package/package.json +1 -1
  62. package/lib/public/js/components/file-viewer.js +0 -1095
  63. package/lib/public/js/components/google.js +0 -228
  64. package/lib/server/routes/browse.js +0 -500
@@ -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,202 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import { useState } from "https://esm.sh/preact/hooks";
3
+ import htm from "https://esm.sh/htm";
4
+ import { LoadingSpinner } from "../loading-spinner.js";
5
+ import { ConfirmDialog } from "../confirm-dialog.js";
6
+ import { SqliteViewer } from "./sqlite-viewer.js";
7
+ import { FileViewerToolbar } from "./toolbar.js";
8
+ import { FileViewerStatusBanners } from "./status-banners.js";
9
+ import { FrontmatterPanel } from "./frontmatter-panel.js";
10
+ import { DiffViewer } from "./diff-viewer.js";
11
+ import { MediaPreview } from "./media-preview.js";
12
+ import { EditorSurface } from "./editor-surface.js";
13
+ import { MarkdownSplitView } from "./markdown-split-view.js";
14
+ import { kSqlitePageSize } from "./constants.js";
15
+ import { useFileViewer } from "./use-file-viewer.js";
16
+
17
+ const html = htm.bind(h);
18
+
19
+ export const FileViewer = ({
20
+ filePath = "",
21
+ isPreviewOnly = false,
22
+ browseView = "edit",
23
+ onRequestEdit = () => {},
24
+ onRequestClearSelection = () => {},
25
+ }) => {
26
+ const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
27
+ const { state, derived, refs, actions, context } = useFileViewer({
28
+ filePath,
29
+ isPreviewOnly,
30
+ browseView,
31
+ onRequestClearSelection,
32
+ onRequestEdit,
33
+ });
34
+
35
+ if (!state.hasSelectedPath) {
36
+ return html`
37
+ <div class="file-viewer-empty">
38
+ <div class="file-viewer-empty-mark">[ ]</div>
39
+ <div class="file-viewer-empty-title">Browse and edit files<br />Syncs to git</div>
40
+ </div>
41
+ `;
42
+ }
43
+
44
+ return html`
45
+ <div class="file-viewer">
46
+ <${FileViewerToolbar}
47
+ pathSegments=${derived.pathSegments}
48
+ isDirty=${derived.isDirty}
49
+ isPreviewOnly=${state.isPreviewOnly}
50
+ isDiffView=${state.isDiffView}
51
+ isMarkdownFile=${state.isMarkdownFile}
52
+ viewMode=${state.viewMode}
53
+ handleChangeViewMode=${actions.handleChangeViewMode}
54
+ handleSave=${actions.handleSave}
55
+ loading=${state.loading}
56
+ canEditFile=${derived.canEditFile}
57
+ isEditBlocked=${derived.isEditBlocked}
58
+ isImageFile=${state.isImageFile}
59
+ isAudioFile=${state.isAudioFile}
60
+ isSqliteFile=${state.isSqliteFile}
61
+ saving=${state.saving}
62
+ deleting=${state.deleting}
63
+ restoring=${state.restoring}
64
+ canDeleteFile=${derived.canDeleteFile}
65
+ isDeleteBlocked=${derived.isDeleteBlocked}
66
+ isProtectedFile=${derived.isProtectedFile}
67
+ canRestoreDeletedDiff=${state.isDiffView && !!state.diffStatus?.isDeleted}
68
+ onRequestDelete=${() => setDeleteConfirmOpen(true)}
69
+ onRequestRestore=${actions.handleRestore}
70
+ />
71
+ <${FileViewerStatusBanners}
72
+ isDiffView=${state.isDiffView}
73
+ onRequestEdit=${onRequestEdit}
74
+ normalizedPath=${context.normalizedPath}
75
+ isDeletedDiff=${!!state.diffStatus?.isDeleted}
76
+ isLockedFile=${derived.isLockedFile}
77
+ isProtectedFile=${derived.isProtectedFile}
78
+ isProtectedLocked=${derived.isProtectedLocked}
79
+ handleEditProtectedFile=${actions.handleEditProtectedFile}
80
+ />
81
+ ${!state.isDiffView
82
+ ? html`
83
+ <${FrontmatterPanel}
84
+ isMarkdownFile=${state.isMarkdownFile}
85
+ parsedFrontmatter=${derived.parsedFrontmatter}
86
+ frontmatterCollapsed=${state.frontmatterCollapsed}
87
+ setFrontmatterCollapsed=${actions.setFrontmatterCollapsed}
88
+ />
89
+ `
90
+ : null}
91
+ ${state.loading
92
+ ? html`
93
+ <div class="file-viewer-loading-shell">
94
+ ${state.showDelayedLoadingSpinner
95
+ ? html`<${LoadingSpinner} className="h-4 w-4" />`
96
+ : null}
97
+ </div>
98
+ `
99
+ : state.error
100
+ ? html`<div class="file-viewer-state file-viewer-state-error">${state.error}</div>`
101
+ : state.isFolderPath
102
+ ? html`
103
+ <div class="file-viewer-state">
104
+ Folder selected. Choose a file from this folder in the tree.
105
+ </div>
106
+ `
107
+ : state.isImageFile || state.isAudioFile
108
+ ? html`
109
+ <${MediaPreview}
110
+ isImageFile=${state.isImageFile}
111
+ imageDataUrl=${state.imageDataUrl}
112
+ pathSegments=${derived.pathSegments}
113
+ isAudioFile=${state.isAudioFile}
114
+ audioDataUrl=${state.audioDataUrl}
115
+ />
116
+ `
117
+ : state.isSqliteFile
118
+ ? html`
119
+ <${SqliteViewer}
120
+ sqliteSummary=${state.sqliteSummary}
121
+ sqliteSelectedTable=${state.sqliteSelectedTable}
122
+ setSqliteSelectedTable=${actions.setSqliteSelectedTable}
123
+ sqliteTableOffset=${state.sqliteTableOffset}
124
+ setSqliteTableOffset=${actions.setSqliteTableOffset}
125
+ sqliteTableLoading=${state.sqliteTableLoading}
126
+ sqliteTableError=${state.sqliteTableError}
127
+ sqliteTableData=${state.sqliteTableData}
128
+ kSqlitePageSize=${kSqlitePageSize}
129
+ />
130
+ `
131
+ : state.isDiffView
132
+ ? html`
133
+ <${DiffViewer}
134
+ diffLoading=${state.diffLoading}
135
+ diffError=${state.diffError}
136
+ diffContent=${state.diffContent}
137
+ />
138
+ `
139
+ : html`
140
+ ${state.isMarkdownFile
141
+ ? html`
142
+ <${MarkdownSplitView}
143
+ viewMode=${state.viewMode}
144
+ previewRef=${refs.previewRef}
145
+ handlePreviewScroll=${actions.handlePreviewScroll}
146
+ previewHtml=${state.previewHtml}
147
+ editorLineNumbers=${derived.editorLineNumbers}
148
+ editorLineNumbersRef=${refs.editorLineNumbersRef}
149
+ editorLineNumberRowRefs=${refs.editorLineNumberRowRefs}
150
+ highlightedEditorLines=${derived.highlightedEditorLines}
151
+ editorHighlightRef=${refs.editorHighlightRef}
152
+ editorHighlightLineRefs=${refs.editorHighlightLineRefs}
153
+ editorTextareaRef=${refs.editorTextareaRef}
154
+ renderContent=${state.renderContent}
155
+ handleContentInput=${actions.handleContentInput}
156
+ handleEditorScroll=${actions.handleEditorScroll}
157
+ handleEditorSelectionChange=${actions.handleEditorSelectionChange}
158
+ isEditBlocked=${derived.isEditBlocked}
159
+ isPreviewOnly=${state.isPreviewOnly}
160
+ />
161
+ `
162
+ : html`
163
+ <${EditorSurface}
164
+ editorLineNumbers=${derived.editorLineNumbers}
165
+ editorLineNumbersRef=${refs.editorLineNumbersRef}
166
+ editorLineNumberRowRefs=${refs.editorLineNumberRowRefs}
167
+ shouldUseHighlightedEditor=${derived.shouldUseHighlightedEditor}
168
+ highlightedEditorLines=${derived.highlightedEditorLines}
169
+ editorHighlightRef=${refs.editorHighlightRef}
170
+ editorHighlightLineRefs=${refs.editorHighlightLineRefs}
171
+ editorTextareaRef=${refs.editorTextareaRef}
172
+ renderContent=${state.renderContent}
173
+ handleContentInput=${actions.handleContentInput}
174
+ handleEditorScroll=${actions.handleEditorScroll}
175
+ handleEditorSelectionChange=${actions.handleEditorSelectionChange}
176
+ isEditBlocked=${derived.isEditBlocked}
177
+ isPreviewOnly=${state.isPreviewOnly}
178
+ />
179
+ `}
180
+ `}
181
+ <${ConfirmDialog}
182
+ visible=${deleteConfirmOpen}
183
+ title="Delete file?"
184
+ message=${`Delete ${context.normalizedPath || "this file"}? This can be restored from diff view before sync.`}
185
+ confirmLabel="Delete"
186
+ confirmLoadingLabel="Deleting..."
187
+ cancelLabel="Cancel"
188
+ confirmTone="warning"
189
+ confirmLoading=${state.deleting}
190
+ confirmDisabled=${!derived.canDeleteFile || state.deleting}
191
+ onCancel=${() => {
192
+ if (state.deleting) return;
193
+ setDeleteConfirmOpen(false);
194
+ }}
195
+ onConfirm=${async () => {
196
+ await actions.handleDelete();
197
+ setDeleteConfirmOpen(false);
198
+ }}
199
+ />
200
+ </div>
201
+ `;
202
+ };
@@ -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
+ };