@gmickel/gno 0.9.0 → 0.9.2
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 +71 -59
- package/assets/screenshots/claudecodeskill.jpg +0 -0
- package/assets/screenshots/cli.jpg +0 -0
- package/assets/screenshots/raycast-mcp.jpg +0 -0
- package/assets/screenshots/webui-ask-answer.jpg +0 -0
- package/assets/screenshots/webui-collections.jpg +0 -0
- package/assets/screenshots/webui-editor.jpg +0 -0
- package/assets/screenshots/webui-home.jpg +0 -0
- package/assets/screenshots/webui-search.jpg +0 -0
- package/package.json +2 -1
- package/src/cli/commands/skill/paths.ts +47 -28
- package/src/serve/public/components/editor/CodeMirrorEditor.tsx +49 -3
- package/src/serve/public/components/editor/MarkdownPreview.tsx +61 -22
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/pages/Ask.tsx +67 -3
- package/src/serve/public/pages/DocView.tsx +48 -4
- package/src/serve/public/pages/DocumentEditor.tsx +111 -2
- package/src/serve/routes/api.ts +4 -0
- package/assets/screenshots/webui-ask-answer.png +0 -0
- package/assets/screenshots/webui-collections.png +0 -0
- package/assets/screenshots/webui-editor.png +0 -0
- package/assets/screenshots/webui-home.png +0 -0
package/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# GNO
|
|
2
2
|
|
|
3
|
-
**Your Local Second Brain
|
|
3
|
+
**Your Local Second Brain**: Index, search, and synthesize your entire digital life.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@gmickel/gno)
|
|
6
6
|
[](./LICENSE)
|
|
7
7
|
[](https://gno.sh)
|
|
8
8
|
[](https://twitter.com/gmickel)
|
|
9
9
|
|
|
10
|
-
GNO is a local knowledge engine for privacy-conscious developers and AI agents. Index your notes, code, PDFs, and Office docs. Get hybrid search (BM25 + vector + reranking) and AI-powered answers
|
|
10
|
+
GNO is a local knowledge engine for privacy-conscious developers and AI agents. Index your notes, code, PDFs, and Office docs. Get hybrid search (BM25 + vector + reranking) and AI-powered answers, all running 100% on your machine.
|
|
11
11
|
|
|
12
12
|
---
|
|
13
13
|
|
|
@@ -16,9 +16,9 @@ GNO is a local knowledge engine for privacy-conscious developers and AI agents.
|
|
|
16
16
|
- [Quick Start](#quick-start)
|
|
17
17
|
- [Installation](#installation)
|
|
18
18
|
- [Search Modes](#search-modes)
|
|
19
|
+
- [Agent Integration](#agent-integration)
|
|
19
20
|
- [Web UI](#web-ui)
|
|
20
21
|
- [REST API](#rest-api)
|
|
21
|
-
- [Agent Integration](#agent-integration)
|
|
22
22
|
- [How It Works](#how-it-works)
|
|
23
23
|
- [Features](#features)
|
|
24
24
|
- [Local Models](#local-models)
|
|
@@ -85,7 +85,7 @@ Check status: `gno mcp status`
|
|
|
85
85
|
|
|
86
86
|
#### Skills (Claude Code, Codex, OpenCode)
|
|
87
87
|
|
|
88
|
-
Skills integrate via CLI
|
|
88
|
+
Skills integrate via CLI with no MCP overhead:
|
|
89
89
|
|
|
90
90
|
```bash
|
|
91
91
|
gno skill install --scope user # User-wide
|
|
@@ -106,7 +106,7 @@ gno skill install --target all # Both Claude + Codex
|
|
|
106
106
|
| `gno query` | Hybrid | Best accuracy (BM25 + vector + reranking) |
|
|
107
107
|
| `gno ask --answer` | RAG | Direct answers with citations |
|
|
108
108
|
|
|
109
|
-
**BM25** indexes full documents (not chunks) with Snowball stemming
|
|
109
|
+
**BM25** indexes full documents (not chunks) with Snowball stemming, so "running" matches "run".
|
|
110
110
|
**Vector** embeds chunks with document titles for context awareness.
|
|
111
111
|
|
|
112
112
|
```bash
|
|
@@ -120,29 +120,76 @@ Output formats: `--json`, `--files`, `--csv`, `--md`, `--xml`
|
|
|
120
120
|
|
|
121
121
|
---
|
|
122
122
|
|
|
123
|
+
## Agent Integration
|
|
124
|
+
|
|
125
|
+
Give your local LLM agents a long-term memory. GNO integrates as a Claude Code skill or MCP server, allowing agents to search, read, and cite your local files.
|
|
126
|
+
|
|
127
|
+
### Skills
|
|
128
|
+
|
|
129
|
+
Skills add GNO search to Claude Code/Codex without MCP protocol overhead:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
gno skill install --scope user
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+

|
|
136
|
+
|
|
137
|
+
Then ask your agent: _"Search my notes for the auth discussion"_
|
|
138
|
+
|
|
139
|
+
[Skill setup guide →](https://gno.sh/docs/integrations/skills/)
|
|
140
|
+
|
|
141
|
+
### MCP Server
|
|
142
|
+
|
|
143
|
+
Connect GNO to Claude Desktop, Cursor, Raycast, and more:
|
|
144
|
+
|
|
145
|
+

|
|
146
|
+
|
|
147
|
+
GNO exposes 6 tools via [Model Context Protocol](https://modelcontextprotocol.io):
|
|
148
|
+
|
|
149
|
+
| Tool | Description |
|
|
150
|
+
| :-------------- | :-------------------------- |
|
|
151
|
+
| `gno_search` | BM25 keyword search |
|
|
152
|
+
| `gno_vsearch` | Vector semantic search |
|
|
153
|
+
| `gno_query` | Hybrid search (recommended) |
|
|
154
|
+
| `gno_get` | Retrieve document by ID |
|
|
155
|
+
| `gno_multi_get` | Batch document retrieval |
|
|
156
|
+
| `gno_status` | Index health check |
|
|
157
|
+
|
|
158
|
+
**Design**: MCP tools are retrieval-only. Your AI assistant (Claude, GPT-4) synthesizes answers from retrieved context. Best retrieval (GNO) + best reasoning (your LLM).
|
|
159
|
+
|
|
160
|
+
[MCP setup guide →](https://gno.sh/docs/MCP/)
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
123
164
|
## Web UI
|
|
124
165
|
|
|
125
|
-
Visual dashboard for search, browsing, editing, and AI answers
|
|
166
|
+
Visual dashboard for search, browsing, editing, and AI answers. Right in your browser.
|
|
126
167
|
|
|
127
168
|
```bash
|
|
128
169
|
gno serve # Start on port 3000
|
|
129
170
|
gno serve --port 8080 # Custom port
|
|
130
171
|
```
|
|
131
172
|
|
|
132
|
-

|
|
133
174
|
|
|
134
175
|
Open `http://localhost:3000` to:
|
|
135
176
|
|
|
136
|
-
- **Search
|
|
137
|
-
- **Browse
|
|
138
|
-
- **Edit
|
|
139
|
-
- **Ask
|
|
140
|
-
- **Manage Collections
|
|
141
|
-
- **Switch presets
|
|
177
|
+
- **Search**: BM25, vector, or hybrid modes with visual results
|
|
178
|
+
- **Browse**: Paginated document list, filter by collection
|
|
179
|
+
- **Edit**: Create, edit, and delete documents with live preview
|
|
180
|
+
- **Ask**: AI-powered Q&A with citations
|
|
181
|
+
- **Manage Collections**: Add, remove, and re-index collections
|
|
182
|
+
- **Switch presets**: Change models live without restart
|
|
183
|
+
|
|
184
|
+
### Search
|
|
185
|
+
|
|
186
|
+

|
|
187
|
+
|
|
188
|
+
Three retrieval modes: BM25 (keyword), Vector (semantic), or Hybrid (best of both). Adjust search depth for speed vs thoroughness.
|
|
142
189
|
|
|
143
190
|
### Document Editing
|
|
144
191
|
|
|
145
|
-

|
|
146
193
|
|
|
147
194
|
Full-featured markdown editor with:
|
|
148
195
|
|
|
@@ -156,7 +203,7 @@ Full-featured markdown editor with:
|
|
|
156
203
|
|
|
157
204
|
### Collections Management
|
|
158
205
|
|
|
159
|
-

|
|
160
207
|
|
|
161
208
|
- Add collections with folder path input
|
|
162
209
|
- View document count, chunk count, embedding status
|
|
@@ -165,9 +212,9 @@ Full-featured markdown editor with:
|
|
|
165
212
|
|
|
166
213
|
### AI Answers
|
|
167
214
|
|
|
168
|
-

|
|
169
216
|
|
|
170
|
-
Ask questions in natural language
|
|
217
|
+
Ask questions in natural language. GNO searches your documents and synthesizes answers with inline citations linking to sources.
|
|
171
218
|
|
|
172
219
|
Everything runs locally. No cloud, no accounts, no data leaving your machine.
|
|
173
220
|
|
|
@@ -219,41 +266,6 @@ No authentication. No rate limits. Build custom tools, automate workflows, integ
|
|
|
219
266
|
|
|
220
267
|
---
|
|
221
268
|
|
|
222
|
-
## Agent Integration
|
|
223
|
-
|
|
224
|
-
### MCP Server
|
|
225
|
-
|
|
226
|
-

|
|
227
|
-
|
|
228
|
-
GNO exposes 6 tools via [Model Context Protocol](https://modelcontextprotocol.io):
|
|
229
|
-
|
|
230
|
-
| Tool | Description |
|
|
231
|
-
| :-------------- | :-------------------------- |
|
|
232
|
-
| `gno_search` | BM25 keyword search |
|
|
233
|
-
| `gno_vsearch` | Vector semantic search |
|
|
234
|
-
| `gno_query` | Hybrid search (recommended) |
|
|
235
|
-
| `gno_get` | Retrieve document by ID |
|
|
236
|
-
| `gno_multi_get` | Batch document retrieval |
|
|
237
|
-
| `gno_status` | Index health check |
|
|
238
|
-
|
|
239
|
-
**Design**: MCP tools are retrieval-only. Your AI assistant (Claude, GPT-4) synthesizes answers from retrieved context—best retrieval (GNO) + best reasoning (your LLM).
|
|
240
|
-
|
|
241
|
-
### Skills
|
|
242
|
-
|
|
243
|
-
Skills add GNO search to Claude Code/Codex without MCP protocol overhead:
|
|
244
|
-
|
|
245
|
-
```bash
|
|
246
|
-
gno skill install --scope user
|
|
247
|
-
```
|
|
248
|
-
|
|
249
|
-

|
|
250
|
-
|
|
251
|
-
Then ask your agent: _"Search my notes for the auth discussion"_
|
|
252
|
-
|
|
253
|
-
> **Detailed docs**: [MCP Integration](https://gno.sh/docs/MCP/) · [Use Cases](https://gno.sh/docs/USE-CASES/)
|
|
254
|
-
|
|
255
|
-
---
|
|
256
|
-
|
|
257
269
|
## How It Works
|
|
258
270
|
|
|
259
271
|
```mermaid
|
|
@@ -279,11 +291,11 @@ graph TD
|
|
|
279
291
|
M --> N[Final Results]
|
|
280
292
|
```
|
|
281
293
|
|
|
282
|
-
0. **Strong Signal Check
|
|
283
|
-
1. **Query Expansion
|
|
284
|
-
2. **Parallel Retrieval
|
|
285
|
-
3. **Fusion
|
|
286
|
-
4. **Reranking
|
|
294
|
+
0. **Strong Signal Check**: Skip expansion if BM25 has confident match (saves 1-3s)
|
|
295
|
+
1. **Query Expansion**: LLM generates lexical variants, semantic rephrases, and a [HyDE](https://arxiv.org/abs/2212.10496) passage
|
|
296
|
+
2. **Parallel Retrieval**: Document-level BM25 + chunk-level vector search on all variants
|
|
297
|
+
3. **Fusion**: RRF with 2× weight for original query, tiered bonus for top ranks
|
|
298
|
+
4. **Reranking**: Qwen3-Reranker scores full documents (32K context), blended with fusion
|
|
287
299
|
|
|
288
300
|
> **Deep dive**: [How Search Works](https://gno.sh/docs/HOW-SEARCH-WORKS/)
|
|
289
301
|
|
|
@@ -298,12 +310,12 @@ graph TD
|
|
|
298
310
|
| **Web UI** | Visual dashboard for search, browse, edit, and AI Q&A |
|
|
299
311
|
| **REST API** | HTTP API for custom tools and integrations |
|
|
300
312
|
| **Multi-Format** | Markdown, PDF, DOCX, XLSX, PPTX, plain text |
|
|
301
|
-
| **Local LLM** | AI answers via llama.cpp
|
|
313
|
+
| **Local LLM** | AI answers via llama.cpp, no API keys |
|
|
302
314
|
| **Privacy First** | 100% offline, zero telemetry, your data stays yours |
|
|
303
315
|
| **MCP Server** | Works with Claude Desktop, Cursor, Zed, + 8 more |
|
|
304
316
|
| **Collections** | Organize sources with patterns, excludes, contexts |
|
|
305
317
|
| **Multilingual** | 30+ languages, auto-detection, cross-lingual search |
|
|
306
|
-
| **Incremental** | SHA-256 tracking
|
|
318
|
+
| **Incremental** | SHA-256 tracking, only changed files re-indexed |
|
|
307
319
|
| **Keyboard First** | ⌘N capture, ⌘K search, ⌘/ shortcuts, ⌘S save |
|
|
308
320
|
|
|
309
321
|
---
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gmickel/gno",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
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",
|
|
@@ -100,6 +100,7 @@
|
|
|
100
100
|
"react-dom": "^19.2.3",
|
|
101
101
|
"react-markdown": "^10.1.0",
|
|
102
102
|
"rehype-sanitize": "^6.0.0",
|
|
103
|
+
"remark-gfm": "^4.0.1",
|
|
103
104
|
"shiki": "^3.20.0",
|
|
104
105
|
"sqlite-vec": "^0.1.7-alpha.2",
|
|
105
106
|
"streamdown": "^1.6.10",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Path resolution for skill installation.
|
|
3
3
|
* Supports Claude Code and Codex targets with project/user scopes.
|
|
4
|
+
* Note: OpenCode and Amp use the same .claude path as Claude Code.
|
|
4
5
|
*
|
|
5
6
|
* @module src/cli/commands/skill/paths
|
|
6
7
|
*/
|
|
@@ -28,6 +29,8 @@ export const ENV_CODEX_SKILLS_DIR = "CODEX_SKILLS_DIR";
|
|
|
28
29
|
export type SkillScope = "project" | "user";
|
|
29
30
|
export type SkillTarget = "claude" | "codex";
|
|
30
31
|
|
|
32
|
+
export const SKILL_TARGETS: SkillTarget[] = ["claude", "codex"];
|
|
33
|
+
|
|
31
34
|
export interface SkillPathOptions {
|
|
32
35
|
scope: SkillScope;
|
|
33
36
|
target: SkillTarget;
|
|
@@ -53,13 +56,27 @@ export interface SkillPaths {
|
|
|
53
56
|
/** Skill name for the gno skill directory */
|
|
54
57
|
export const SKILL_NAME = "gno";
|
|
55
58
|
|
|
56
|
-
/**
|
|
57
|
-
|
|
59
|
+
/** Path configuration per target */
|
|
60
|
+
interface TargetPathConfig {
|
|
61
|
+
projectBase: string; // e.g., ".claude"
|
|
62
|
+
userBase: string; // e.g., ".claude" (joined with homedir) or ".config/opencode"
|
|
63
|
+
skillsSubdir: string; // e.g., "skills" or "skill"
|
|
64
|
+
envVar: string;
|
|
65
|
+
}
|
|
58
66
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
67
|
+
const TARGET_CONFIGS: Record<SkillTarget, TargetPathConfig> = {
|
|
68
|
+
claude: {
|
|
69
|
+
projectBase: ".claude",
|
|
70
|
+
userBase: ".claude",
|
|
71
|
+
skillsSubdir: "skills",
|
|
72
|
+
envVar: ENV_CLAUDE_SKILLS_DIR,
|
|
73
|
+
},
|
|
74
|
+
codex: {
|
|
75
|
+
projectBase: ".codex",
|
|
76
|
+
userBase: ".codex",
|
|
77
|
+
skillsSubdir: "skills",
|
|
78
|
+
envVar: ENV_CODEX_SKILLS_DIR,
|
|
79
|
+
},
|
|
63
80
|
};
|
|
64
81
|
|
|
65
82
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -71,19 +88,15 @@ const AGENT_DIRS: Record<SkillTarget, string> = {
|
|
|
71
88
|
*/
|
|
72
89
|
export function resolveSkillPaths(opts: SkillPathOptions): SkillPaths {
|
|
73
90
|
const { scope, target, cwd, homeDir } = opts;
|
|
91
|
+
const config = TARGET_CONFIGS[target];
|
|
74
92
|
|
|
75
93
|
// Check for env overrides first
|
|
76
|
-
const envOverride =
|
|
77
|
-
target === "claude"
|
|
78
|
-
? process.env[ENV_CLAUDE_SKILLS_DIR]
|
|
79
|
-
: process.env[ENV_CODEX_SKILLS_DIR];
|
|
94
|
+
const envOverride = process.env[config.envVar];
|
|
80
95
|
|
|
81
96
|
if (envOverride) {
|
|
82
97
|
// Require absolute path for security
|
|
83
98
|
if (!isAbsolute(envOverride)) {
|
|
84
|
-
throw new Error(
|
|
85
|
-
`${target === "claude" ? ENV_CLAUDE_SKILLS_DIR : ENV_CODEX_SKILLS_DIR} must be an absolute path`
|
|
86
|
-
);
|
|
99
|
+
throw new Error(`${config.envVar} must be an absolute path`);
|
|
87
100
|
}
|
|
88
101
|
const skillsDir = normalize(envOverride);
|
|
89
102
|
return {
|
|
@@ -94,18 +107,17 @@ export function resolveSkillPaths(opts: SkillPathOptions): SkillPaths {
|
|
|
94
107
|
}
|
|
95
108
|
|
|
96
109
|
// Resolve base directory
|
|
97
|
-
const agentDir = AGENT_DIRS[target];
|
|
98
110
|
let base: string;
|
|
99
111
|
|
|
100
112
|
if (scope === "user") {
|
|
101
113
|
const home = homeDir ?? process.env[ENV_SKILLS_HOME_OVERRIDE] ?? homedir();
|
|
102
|
-
base = join(home,
|
|
114
|
+
base = join(home, config.userBase);
|
|
103
115
|
} else {
|
|
104
116
|
const projectRoot = cwd ?? process.cwd();
|
|
105
|
-
base = join(projectRoot,
|
|
117
|
+
base = join(projectRoot, config.projectBase);
|
|
106
118
|
}
|
|
107
119
|
|
|
108
|
-
const skillsDir = join(base,
|
|
120
|
+
const skillsDir = join(base, config.skillsSubdir);
|
|
109
121
|
const gnoDir = join(skillsDir, SKILL_NAME);
|
|
110
122
|
|
|
111
123
|
return { base, skillsDir, gnoDir };
|
|
@@ -120,8 +132,7 @@ export function resolveAllPaths(
|
|
|
120
132
|
overrides?: { cwd?: string; homeDir?: string }
|
|
121
133
|
): Array<{ scope: SkillScope; target: SkillTarget; paths: SkillPaths }> {
|
|
122
134
|
const scopes: SkillScope[] = scope === "all" ? ["project", "user"] : [scope];
|
|
123
|
-
const targets: SkillTarget[] =
|
|
124
|
-
target === "all" ? ["claude", "codex"] : [target];
|
|
135
|
+
const targets: SkillTarget[] = target === "all" ? SKILL_TARGETS : [target];
|
|
125
136
|
|
|
126
137
|
const results: Array<{
|
|
127
138
|
scope: SkillScope;
|
|
@@ -147,11 +158,16 @@ export function resolveAllPaths(
|
|
|
147
158
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
148
159
|
|
|
149
160
|
/**
|
|
150
|
-
*
|
|
151
|
-
*
|
|
161
|
+
* Get expected path suffixes for gno skill directory.
|
|
162
|
+
* Returns all valid suffixes since different targets use different subdir names.
|
|
152
163
|
*/
|
|
153
|
-
function
|
|
154
|
-
|
|
164
|
+
function getExpectedSuffixes(): string[] {
|
|
165
|
+
const subdirs = new Set(
|
|
166
|
+
Object.values(TARGET_CONFIGS).map((c) => c.skillsSubdir)
|
|
167
|
+
);
|
|
168
|
+
return Array.from(subdirs).map(
|
|
169
|
+
(subdir) => `${sep}${subdir}${sep}${SKILL_NAME}`
|
|
170
|
+
);
|
|
155
171
|
}
|
|
156
172
|
|
|
157
173
|
/**
|
|
@@ -164,11 +180,14 @@ export function validatePathForDeletion(
|
|
|
164
180
|
): string | null {
|
|
165
181
|
const normalized = normalize(destDir);
|
|
166
182
|
const normalizedBase = normalize(base);
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
// Must end with /skills/gno
|
|
170
|
-
|
|
171
|
-
|
|
183
|
+
const expectedSuffixes = getExpectedSuffixes();
|
|
184
|
+
|
|
185
|
+
// Must end with /skills/gno or /skill/gno (platform-aware)
|
|
186
|
+
const hasValidSuffix = expectedSuffixes.some((suffix) =>
|
|
187
|
+
normalized.endsWith(suffix)
|
|
188
|
+
);
|
|
189
|
+
if (!hasValidSuffix) {
|
|
190
|
+
return `Path does not end with expected suffix (${expectedSuffixes.join(" or ")})`;
|
|
172
191
|
}
|
|
173
192
|
|
|
174
193
|
// Minimum length sanity check
|
|
@@ -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} />;
|