@bhsd/codemirror-stickyscroll 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zulfazli (Fazelllyyy)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,135 @@
1
+ # @bhsd/codemirror-stickyscroll
2
+
3
+ > VS Code / Monaco-style **sticky scroll** (sticky lines) for [CodeMirror 6](https://codemirror.net/).
4
+
5
+ <p align="center">
6
+ <img src="preview/image.png" alt="Sticky Scroll Preview" width="750" />
7
+ </p>
8
+
9
+ This is a fork of [@fazelstudio/codemirror-stickyscroll](https://github.com/fazel-studio/codemirror-stickyscroll).
10
+
11
+ Sticky lines keep the *opening* lines of the enclosing scopes (function, class,
12
+ if/loop blocks, …) pinned at the top of the editor while you scroll — exactly
13
+ like VS Code's sticky scroll, but built **purely as an external CodeMirror 6
14
+ extension** (no fork of `@codemirror/*`).
15
+
16
+ ## Features
17
+
18
+ - **Per-pixel updates** — a native `scroll` listener + `requestAnimationFrame`
19
+ throttle keeps the bar glued to the scroll position; no "jumpy" updates.
20
+ - **Click-to-jump with margin compensation** — clicking a sticky line scrolls
21
+ the target line to the top *plus* the current bar height, so the line you
22
+ jump to is never hidden behind the bar. Keyboard (`Enter`/`Space`) + `role="button"` for a11y.
23
+ - **Reuses the consumer's theme** — the bar re-highlights lines through the
24
+ *active* highlight styles of the editor (`highlightingFor`), or clones the
25
+ already-rendered DOM line when available. The package **never** registers its
26
+ own `syntaxHighlighting(...)`.
27
+ - **Language-agnostic** — detection is based on `foldable()` from
28
+ `@codemirror/language` (the fold services / fold node props that every
29
+ `@codemirror/lang-*` already registers). It includes a smart, generic denylist
30
+ that works across multiple languages (JS/TS, Python, Rust, Go, etc.) out of the box,
31
+ and gracefully handles data languages like JSON.
32
+ - **Gutter alignment, horizontal sync, RTL, resize-proof** — line numbers are
33
+ aligned with the real gutter (width tracked via `ResizeObserver`), the bar
34
+ follows horizontal scroll, and it reacts to font/zoom/resize changes.
35
+ - **Zero runtime deps** — peer dependencies only.
36
+
37
+ ## Installation
38
+
39
+ ```bash
40
+ npm install @bhsd/codemirror-stickyscroll
41
+ ```
42
+
43
+ The following are **peer dependencies** (already present in any project that
44
+ has a working CodeMirror 6 editor):
45
+
46
+ | Package | Minimum |
47
+ | --- | --- |
48
+ | `@codemirror/view` | ^6.0.0 |
49
+ | `@codemirror/state` | ^6.0.0 |
50
+ | `@codemirror/language` | ^6.0.0 |
51
+ | `@lezer/common` | ^1.0.0 |
52
+ | `@lezer/highlight` | ^1.0.0 |
53
+
54
+ ## API
55
+
56
+ ### `stickyScroll(options?: StickyScrollOptions): Extension`
57
+
58
+ ```ts
59
+ interface StickyScrollOptions {
60
+ /** Maximum sticky lines shown at once (dynamically clamped to ~40% of editor height). Default: 4 */
61
+ maxStickyLines?: number;
62
+ /** Minimum lines a scope must span to become sticky. Default: 2 */
63
+ minBlockLines?: number;
64
+ /**
65
+ * Denylist predicate: return true to never pin a foldable node of that type.
66
+ * Defaults to `defaultExcludeNode` (imports + data literals + comments).
67
+ * It uses exact node names for JS/TS and generic regex patterns for other
68
+ * languages, while automatically bypassing the literal denylist for JSON.
69
+ */
70
+ excludeNode?: (nodeName: string, langName: string | undefined) => boolean;
71
+ /** Extra HighlightStyle merged in when a line is re-highlighted from scratch. */
72
+ highlightStyle?: HighlightStyle;
73
+ /** Called after a sticky line is clicked (after the jump is dispatched). */
74
+ onLineClick?: (lineNumber: number) => void;
75
+ /** Extra CSS class(es) for the bar container. */
76
+ class?: string;
77
+ }
78
+ ```
79
+
80
+ ### Re-exports
81
+
82
+ ```ts
83
+ import {
84
+ stickyScroll,
85
+ stickyScrollFacet, // the configuration Facet (compose/override per instance)
86
+ defaultExcludeNode, // default denylist implementation
87
+ makeStickyScrollConfig, // merge options with defaults
88
+ stickyScrollBaseTheme, // layout-only base theme (no token colors)
89
+ type StickyScrollOptions,
90
+ type StickyLine,
91
+ } from "@bhsd/codemirror-stickyscroll";
92
+ ```
93
+
94
+ ## Styling
95
+
96
+ The bar is layout-only by default and always follows the **editor's own theme**
97
+ (token colors, background). You can restyle it with CSS.
98
+
99
+ Per-row hooks:
100
+
101
+ | Class | Purpose |
102
+ | --- | --- |
103
+ | `.cm-stickyscroll-container` | the overlay bar |
104
+ | `.cm-stickyscroll-line` | one breadcrumb row |
105
+ | `.cm-stickyscroll-line:hover` | hover highlight |
106
+ | `.cm-stickyscroll-line.cm-stickyscroll-current` | block containing the cursor |
107
+ | `.cm-stickyscroll-gutter` | line-number cell |
108
+ | `.cm-stickyscroll-text` | code column (horizontally synced) |
109
+
110
+ ## How it works
111
+
112
+ 1. **Detect the top line** — `view.lineBlockAtHeight(scrollTop - documentTop)`.
113
+ 2. **Walk the syntax tree** — from `syntaxTree(state).resolveInner(topPos, 0)`
114
+ up through `node.parent`.
115
+ 3. **A scope qualifies when** its opening line is *foldable*
116
+ (`foldable(state, line.from, line.to)` from `@codemirror/language`), its fold
117
+ anchor node is not denylisted, and it spans ≥ `minBlockLines`.
118
+ 4. **Render** — a `ViewPlugin` owns an absolutely-positioned overlay
119
+ (`position: absolute; top: 0` inside `view.dom`), updated per-frame from the
120
+ native `scroll` event; line numbers align with the real gutter; token colors
121
+ come from the consumer's active highlighters.
122
+
123
+ The DOM overlay (rather than the Panel API / `showPanel`) is what makes the bar
124
+ behave like Monaco: it never pushes the content down (no reflow) and it can be
125
+ updated per-pixel, which panels cannot (panels only re-render on `ViewUpdate`,
126
+ not on pure scroll).
127
+
128
+ ## Language support
129
+
130
+ Works out of the box with any `@codemirror/lang-*` (or community grammar) that
131
+ registers folding — JS/TS, Rust, Python, Java, Go, C#, etc.
132
+
133
+ ## License
134
+
135
+ MIT — © Zulfazli (Fazelllyyy) · [https://github.com/fazelllyyy](https://github.com/fazelllyyy)
@@ -0,0 +1,7 @@
1
+ import type { EditorView } from "@codemirror/view";
2
+ import type { StickyScrollConfig } from "./facet";
3
+ import type { StickyLine } from "./types";
4
+ /**
5
+ * Compute the sticky context for the current scroll position (viewport top).
6
+ */
7
+ export declare function getStickyContext(view: EditorView, config: StickyScrollConfig): StickyLine[];
@@ -0,0 +1,168 @@
1
+ import { ensureSyntaxTree, foldNodeProp, foldService, language, syntaxTree } from "@codemirror/language";
2
+ /**
3
+ * Milliseconds of synchronous parse work we are allowed to spend per sticky
4
+ * update when the committed syntax tree has not caught up with the viewport
5
+ * yet (large files / big scroll jumps). The background parser keeps working
6
+ * between frames and commits its results, so this only accelerates the first
7
+ * reveal; once the tree covers the position the cost is O(1).
8
+ */
9
+ const PARSE_TIMEOUT = 25;
10
+ /**
11
+ * Return the best syntax tree we can answer a query at `pos` with right now.
12
+ *
13
+ * When the committed tree already covers `pos`, it is used directly (cheap).
14
+ * Otherwise we synchronously advance the parser for a bounded amount of time
15
+ * and use the extended tree if it reached `pos`, falling back to the committed
16
+ * tree otherwise (the background parser commits the rest shortly after).
17
+ *
18
+ * This is what keeps the sticky bar correct on files with thousands of lines:
19
+ * without it, `syntaxTree(state)` still holds a stale, short parse right after
20
+ * a scroll jump, and the ancestor walk would clamp to the document root and
21
+ * return no sticky lines at all.
22
+ */
23
+ function treeUpTo(state, pos) {
24
+ var _a;
25
+ const committed = syntaxTree(state);
26
+ if (committed.length >= pos)
27
+ return committed;
28
+ return (_a = ensureSyntaxTree(state, pos, PARSE_TIMEOUT)) !== null && _a !== void 0 ? _a : committed;
29
+ }
30
+ /**
31
+ * Compute the sticky context for the current scroll position (viewport top).
32
+ */
33
+ export function getStickyContext(view, config) {
34
+ const state = view.state;
35
+ const lang = state.facet(language);
36
+ if (!lang)
37
+ return [];
38
+ const tree = syntaxTree(state);
39
+ if (tree.length === 0)
40
+ return [];
41
+ // Calculate the document-relative Y of the top edge of the visible viewport.
42
+ // `view.documentTop` is the client Y of the document's top edge.
43
+ // The viewport's top client Y is `scrollDOM.getBoundingClientRect().top`.
44
+ const clientTop = view.scrollDOM.getBoundingClientRect().top;
45
+ let y = clientTop - view.documentTop;
46
+ if (y < 0)
47
+ y = 0;
48
+ const topBlock = view.lineBlockAtHeight(y);
49
+ if (topBlock.from > state.doc.length)
50
+ return [];
51
+ // Make sure the parser has covered at least the whole rendered range before
52
+ // walking ancestors, so deeply-scrolled positions in big files resolve to
53
+ // real scopes instead of the (clamped) document root.
54
+ const upto = Math.min(view.viewport.to, state.doc.length);
55
+ const parseTree = treeUpTo(state, upto);
56
+ // Dynamic clamp: limit sticky bar to max ~40% of the editor height
57
+ const lineHeight = Math.round(view.defaultLineHeight) || 16;
58
+ const maxDynamic = Math.max(1, Math.floor(view.scrollDOM.clientHeight * 0.4 / lineHeight));
59
+ const maxSticky = Math.min(config.maxStickyLines, maxDynamic);
60
+ return getStickyContextForRange(view, topBlock.from, config, parseTree, maxSticky);
61
+ }
62
+ /**
63
+ * Pure version of the algorithm.
64
+ *
65
+ * `tree` is optional and defaults to the committed syntax tree; pass an
66
+ * already-extended tree (see `getStickyContext`) to compute against a parse
67
+ * that is known to cover `fromPos`.
68
+ */
69
+ function getStickyContextForRange({ state, viewport }, fromPos, config, t, maxSticky) {
70
+ const lang = state.facet(language);
71
+ if (!lang)
72
+ return [];
73
+ if (t.length === 0)
74
+ return [];
75
+ const doc = state.doc;
76
+ if (fromPos > doc.length)
77
+ return [];
78
+ const topLineNumber = doc.lineAt(fromPos).number;
79
+ const minBlockLines = config.minBlockLines;
80
+ const exclude = config.excludeNode;
81
+ const langName = lang.name;
82
+ const found = [];
83
+ const services = state.facet(foldService);
84
+ if (services.length === 0) {
85
+ let node = t.resolveInner(fromPos, 0);
86
+ while (node) {
87
+ if (!node.type.isTop && node.from < node.to && node.to <= doc.length) {
88
+ const prop = node.type.prop(foldNodeProp);
89
+ if (prop) {
90
+ const foldRange = prop(node, state);
91
+ if (foldRange) {
92
+ // Find the semantic owner of this foldable block
93
+ let owner = node;
94
+ if (node.parent && !node.parent.type.isTop && !node.parent.type.prop(foldNodeProp)) {
95
+ owner = node.parent;
96
+ }
97
+ const open = doc.lineAt(owner.from);
98
+ const openLine = open.number;
99
+ // Opening line must already be scrolled out above the viewport, and must not be blank.
100
+ if (openLine < topLineNumber && open.text.trim() !== "") {
101
+ // The denylist describes *foldable* node types (data literals,
102
+ // comments, imports). `owner` may be a wrapper (e.g.
103
+ // VariableDeclaration wrapping an ObjectExpression), so evaluate
104
+ // against the foldable node's own type name.
105
+ const typeName = node.name;
106
+ if (!exclude(typeName, langName, owner.name)) {
107
+ // A scope only counts as "over" once its full semantic end has
108
+ // passed, so measure from `owner.to` (e.g. the whole try/catch or
109
+ // if/else statement) instead of just the foldable block. This is
110
+ // what delays the slide-away until the real closing brace.
111
+ const close = doc.lineAt(Math.min(owner.to, doc.length));
112
+ const closeLine = close.number;
113
+ if (closeLine - openLine + 1 >= minBlockLines && closeLine >= topLineNumber) {
114
+ found.push({
115
+ lineNumber: openLine,
116
+ from: open.from,
117
+ to: open.to,
118
+ text: open.text,
119
+ nodeFrom: owner.from,
120
+ nodeTo: owner.to,
121
+ });
122
+ }
123
+ }
124
+ }
125
+ }
126
+ }
127
+ }
128
+ node = node.parent;
129
+ }
130
+ }
131
+ else {
132
+ for (let openLine = topLineNumber - 1; openLine > 0; openLine--) {
133
+ const open = doc.line(openLine);
134
+ if (open.to < viewport.from)
135
+ break;
136
+ const ownerName = open.text.trim();
137
+ if (!exclude(undefined, langName, ownerName)) {
138
+ for (const service of services) {
139
+ const foldRange = service(state, open.from, open.to);
140
+ if (foldRange) {
141
+ const nodeTo = Math.min(foldRange.to, doc.length);
142
+ const close = doc.lineAt(nodeTo);
143
+ const closeLine = close.number;
144
+ if (closeLine - openLine + 1 >= minBlockLines && closeLine >= topLineNumber) {
145
+ found.push({
146
+ lineNumber: openLine,
147
+ from: open.from,
148
+ to: open.to,
149
+ text: open.text,
150
+ nodeFrom: foldRange.from,
151
+ nodeTo,
152
+ });
153
+ }
154
+ }
155
+ }
156
+ }
157
+ }
158
+ }
159
+ // `found` is collected innermost → outermost. Reverse to outermost → innermost,
160
+ // dedup by opening line (keep the outermost node on that line), then cap.
161
+ found.reverse();
162
+ const dedup = new Map();
163
+ for (const line of found) {
164
+ if (!dedup.has(line.lineNumber))
165
+ dedup.set(line.lineNumber, line);
166
+ }
167
+ return Array.from(dedup.values()).slice(0, maxSticky);
168
+ }
@@ -0,0 +1,47 @@
1
+ import { Facet } from "@codemirror/state";
2
+ /**
3
+ * Public options accepted by `stickyScroll()`.
4
+ *
5
+ * All fields are optional; defaults are documented below.
6
+ */
7
+ export interface StickyScrollOptions {
8
+ /** Maximum sticky lines shown at once. Default: 4 */
9
+ maxStickyLines?: number;
10
+ /** Minimum number of lines a scope must span before it becomes sticky. Default: 6 */
11
+ minBlockLines?: number;
12
+ /**
13
+ * Denylist predicate. When it returns `true` for a foldable syntax node,
14
+ * that node is never turned into a sticky line, although it could be folded.
15
+ * Defaults to `defaultExcludeNode`, which excludes imports and data
16
+ * literals — exact node names for JS/TS, best-effort name-pattern
17
+ * matching for other grammars. Override for fine-tuning, especially for
18
+ * a specific non-JS/TS language.
19
+ */
20
+ excludeNode?: (nodeName?: string, langName?: string, ownerName?: string) => boolean;
21
+ /** Extra CSS class(es) applied to the sticky bar container. */
22
+ class?: string;
23
+ }
24
+ /** Resolved configuration produced by `stickyScrollFacet`. */
25
+ export interface StickyScrollConfig extends Required<Pick<StickyScrollOptions, "maxStickyLines" | "minBlockLines" | "excludeNode">> {
26
+ class?: string | undefined;
27
+ }
28
+ /**
29
+ * Default denylist (see §4.3 of the design doc): nodes that *are* foldable but
30
+ * semantically are not navigation scopes worth pinning (imports, big data
31
+ * literals, comment blocks).
32
+ *
33
+ * Precise for JS/TS out of the box (`JS_TS_DENYLIST`); best-effort pattern
34
+ * match for everything else. Override via `StickyScrollOptions.excludeNode`
35
+ * for a language you need exact behavior on.
36
+ */
37
+ export declare const defaultExcludeNode: (nodeName?: string, langName?: string, ownerName?: string) => boolean;
38
+ /** Merge partial options with built-in defaults. */
39
+ export declare function makeStickyScrollConfig(options?: StickyScrollOptions): StickyScrollConfig;
40
+ /**
41
+ * The facet carrying the sticky-scroll configuration.
42
+ *
43
+ * Multiple `stickyScroll()` instances compose; the *last* registered value wins
44
+ * (override semantics). Consumers may always read/override it per-instance via
45
+ * `stickyScrollFacet.of(...)`.
46
+ */
47
+ export declare const stickyScrollFacet: Facet<StickyScrollOptions, StickyScrollConfig>;
package/dist/facet.js ADDED
@@ -0,0 +1,81 @@
1
+ import { Facet } from "@codemirror/state";
2
+ /**
3
+ * Exact node names we know for certain, verified against the JS/TS Lezer
4
+ * grammar (`@codemirror/lang-javascript`). Kept as an explicit set (rather
5
+ * than folded into the pattern fallback below) because these are the names
6
+ * we can vouch for precisely.
7
+ */
8
+ const JS_TS_DENYLIST = new Set([
9
+ "ImportDeclaration",
10
+ "ImportStatement",
11
+ "ObjectExpression",
12
+ "ObjectLiteral",
13
+ "ArrayExpression",
14
+ "ArrayLiteral",
15
+ "ObjectPattern",
16
+ "Comment",
17
+ "LineComment",
18
+ "BlockComment",
19
+ "DocComment",
20
+ ]);
21
+ /**
22
+ * Best-effort fallback for grammars we don't special-case above (Python,
23
+ * Rust, Go, CSS, ...). Every Lezer grammar names its nodes independently, so
24
+ * there is no single exact list that covers all of them — but most `lang-*`
25
+ * packages follow a similar naming convention for the three categories that
26
+ * matter here (data literals, comments, import/use declarations). These
27
+ * patterns catch that convention on a best-effort basis; they will not be
28
+ * 100% precise for every language, but they degrade gracefully (a miss just
29
+ * means the node isn't excluded, not a crash or a wrong result elsewhere).
30
+ * For languages where precision matters, pass a language-specific
31
+ * `excludeNode` via `StickyScrollOptions`.
32
+ */
33
+ const LITERAL_NODE_PATTERN = /^(?:Object|Array|List|Dict(?:ionary)?|Set|Record|Map|Tuple)(?:Expression|Literal|Pattern)?$/;
34
+ const IMPORT_NODE_PATTERN = /^(?:Import|Use)(?:Declaration|Statement|Item|Spec)?$/;
35
+ /**
36
+ * Default denylist (see §4.3 of the design doc): nodes that *are* foldable but
37
+ * semantically are not navigation scopes worth pinning (imports, big data
38
+ * literals, comment blocks).
39
+ *
40
+ * Precise for JS/TS out of the box (`JS_TS_DENYLIST`); best-effort pattern
41
+ * match for everything else. Override via `StickyScrollOptions.excludeNode`
42
+ * for a language you need exact behavior on.
43
+ */
44
+ export const defaultExcludeNode = (nodeName, langName, ownerName) => {
45
+ // In pure data languages like JSON, we DO NOT exclude objects/arrays.
46
+ // Because the entire JSON file consists of Objects/Arrays, excluding them
47
+ // would completely disable sticky scroll for the file.
48
+ if (langName === "json" || langName === "jsonc") {
49
+ return ownerName !== "Property" || nodeName.endsWith("Comment");
50
+ }
51
+ // DO NOT exclude in stream-base languages.
52
+ if (nodeName === undefined)
53
+ return false;
54
+ return (JS_TS_DENYLIST.has(nodeName) ||
55
+ nodeName.endsWith("Comment") ||
56
+ LITERAL_NODE_PATTERN.test(nodeName) ||
57
+ IMPORT_NODE_PATTERN.test(nodeName));
58
+ };
59
+ const DEFAULT_MAX_STICKY_LINES = 4;
60
+ const DEFAULT_MIN_BLOCK_LINES = 6;
61
+ /** Merge partial options with built-in defaults. */
62
+ export function makeStickyScrollConfig(options) {
63
+ var _a, _b, _c;
64
+ return {
65
+ maxStickyLines: (_a = options === null || options === void 0 ? void 0 : options.maxStickyLines) !== null && _a !== void 0 ? _a : DEFAULT_MAX_STICKY_LINES,
66
+ minBlockLines: (_b = options === null || options === void 0 ? void 0 : options.minBlockLines) !== null && _b !== void 0 ? _b : DEFAULT_MIN_BLOCK_LINES,
67
+ excludeNode: (_c = options === null || options === void 0 ? void 0 : options.excludeNode) !== null && _c !== void 0 ? _c : defaultExcludeNode,
68
+ class: options === null || options === void 0 ? void 0 : options.class,
69
+ };
70
+ }
71
+ /**
72
+ * The facet carrying the sticky-scroll configuration.
73
+ *
74
+ * Multiple `stickyScroll()` instances compose; the *last* registered value wins
75
+ * (override semantics). Consumers may always read/override it per-instance via
76
+ * `stickyScrollFacet.of(...)`.
77
+ */
78
+ export const stickyScrollFacet = Facet.define({
79
+ compare: (a, b) => a === b,
80
+ combine: (values) => makeStickyScrollConfig(values[values.length - 1]),
81
+ });
@@ -0,0 +1,23 @@
1
+ import type { Extension } from "@codemirror/state";
2
+ import { type StickyScrollOptions } from "./facet";
3
+ export type { StickyLine } from "./types";
4
+ export type { StickyScrollOptions, StickyScrollConfig } from "./facet";
5
+ export { stickyScrollFacet, defaultExcludeNode, makeStickyScrollConfig } from "./facet";
6
+ export { stickyScrollBaseTheme } from "./theme";
7
+ /**
8
+ * Add Monaco/VS Code-style sticky scroll to a CodeMirror 6 editor.
9
+ *
10
+ * ```ts
11
+ * import { stickyScroll } from "@fazelstudio/codemirror-stickyscroll";
12
+ *
13
+ * const view = new EditorView({
14
+ * extensions: [basicSetup, javascript(), stickyScroll({ maxStickyLines: 4 })],
15
+ * parent: el,
16
+ * });
17
+ * ```
18
+ *
19
+ * Note: the returned array deliberately does NOT contain any
20
+ * `syntaxHighlighting(...)` — token colors always come from the consumer's own
21
+ * theme (§4.4).
22
+ */
23
+ export declare function stickyScroll(options?: StickyScrollOptions): Extension;
package/dist/index.js ADDED
@@ -0,0 +1,24 @@
1
+ import { stickyScrollFacet } from "./facet";
2
+ import { scrollStickyPlugin } from "./plugin";
3
+ import { stickyScrollBaseTheme } from "./theme";
4
+ export { stickyScrollFacet, defaultExcludeNode, makeStickyScrollConfig } from "./facet";
5
+ export { stickyScrollBaseTheme } from "./theme";
6
+ /**
7
+ * Add Monaco/VS Code-style sticky scroll to a CodeMirror 6 editor.
8
+ *
9
+ * ```ts
10
+ * import { stickyScroll } from "@fazelstudio/codemirror-stickyscroll";
11
+ *
12
+ * const view = new EditorView({
13
+ * extensions: [basicSetup, javascript(), stickyScroll({ maxStickyLines: 4 })],
14
+ * parent: el,
15
+ * });
16
+ * ```
17
+ *
18
+ * Note: the returned array deliberately does NOT contain any
19
+ * `syntaxHighlighting(...)` — token colors always come from the consumer's own
20
+ * theme (§4.4).
21
+ */
22
+ export function stickyScroll(options = {}) {
23
+ return [stickyScrollFacet.of(options), stickyScrollBaseTheme, scrollStickyPlugin];
24
+ }
@@ -0,0 +1,62 @@
1
+ import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
2
+ import type { StickyLine } from "./types";
3
+ declare class StickyScrollPlugin {
4
+ private readonly dom;
5
+ private readonly inner;
6
+ private readonly view;
7
+ private config;
8
+ private readonly cache;
9
+ private rafHandle;
10
+ private observer;
11
+ private gutterObserver;
12
+ private gutterMutationObserver;
13
+ private gutterMetrics;
14
+ private currentHeight;
15
+ private lastLineKey;
16
+ get lineHeight(): number;
17
+ constructor(view: EditorView);
18
+ clearCache(): void;
19
+ update(update: ViewUpdate): void;
20
+ destroy(): void;
21
+ private onScroll;
22
+ private request;
23
+ private render;
24
+ private hide;
25
+ /**
26
+ * Rebuild all row DOM nodes (called only when line numbers change).
27
+ * We clear the inner wrapper and build fresh — no stale DOM element issues.
28
+ */
29
+ private buildRows;
30
+ /**
31
+ * Update per-frame attributes WITHOUT rebuilding DOM.
32
+ * Called every frame when the line-set is unchanged.
33
+ */
34
+ private updateRowAttrs;
35
+ /**
36
+ * Build a single sticky row element.
37
+ *
38
+ * The gutter overlay mirrors the real editor gutter column-by-column:
39
+ * .cm-stickyscroll-gutter (position:absolute, width = total gutter width)
40
+ */
41
+ private makeRow;
42
+ setClass(row: HTMLDivElement, line: StickyLine): void;
43
+ setWidth(row: HTMLDivElement, gutter: HTMLDivElement): void;
44
+ setHeight(ele: HTMLDivElement): void;
45
+ setTransform(code: HTMLDivElement): void;
46
+ /**
47
+ * Resolve the sticky bar's background to a GUARANTEED-opaque color that
48
+ * matches the editor's effective backdrop.
49
+ *
50
+ * 1. Walk the real ancestor chain (contentDOM → … → <html>) and return the
51
+ * first opaque color, skipping fully transparent AND semi-transparent
52
+ * layers (a semi-transparent ancestor is only used as a last resort).
53
+ * 2. Fall back to a dark/light hex default.
54
+ *
55
+ * This makes it impossible for the sticky bar to end up see-through, exactly
56
+ * like Monaco's sticky widget which always paints a solid background.
57
+ */
58
+ private resolveBackground;
59
+ private detectBg;
60
+ }
61
+ export declare const scrollStickyPlugin: ViewPlugin<StickyScrollPlugin, undefined>;
62
+ export {};
package/dist/plugin.js ADDED
@@ -0,0 +1,333 @@
1
+ import { language, syntaxTree } from "@codemirror/language";
2
+ import { Direction, EditorView, ViewPlugin } from "@codemirror/view";
3
+ import { getStickyContext } from "./compute";
4
+ import { stickyScrollFacet } from "./facet";
5
+ import { renderLineCode } from "./render";
6
+ // ─────────────────────────────────────────────────────────────────────────────
7
+ // Utilities
8
+ // ─────────────────────────────────────────────────────────────────────────────
9
+ /**
10
+ * Alpha channel of a computed `background-color`, or `null` when the color has
11
+ * no alpha channel (hex / named / `rgb(...)` → fully opaque).
12
+ */
13
+ function alphaOf(bg) {
14
+ if (!bg || bg === "transparent")
15
+ return 0;
16
+ const m = /rgba?\(([^)]*)\)/.exec(bg);
17
+ if (!m)
18
+ return 1;
19
+ const parts = m[1].split(",");
20
+ if (parts.length === 4) {
21
+ const a = parseFloat(parts[3]);
22
+ return Number.isNaN(a) ? 1 : a;
23
+ }
24
+ return 1;
25
+ }
26
+ function measureGutters(view) {
27
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
28
+ const guttersEl = view.dom.querySelector(".cm-gutters");
29
+ if (!guttersEl)
30
+ return { totalWidth: 0, columns: [] };
31
+ const totalWidth = guttersEl.offsetWidth;
32
+ const columns = Array.from(guttersEl.children, (child) => ({
33
+ width: child.offsetWidth,
34
+ className: child.className
35
+ }));
36
+ return { totalWidth, columns };
37
+ }
38
+ // ─────────────────────────────────────────────────────────────────────────────
39
+ // VSCode/Monaco-style sticky scroll implementation
40
+ //
41
+ // Reference: stickyScrollWidget.ts + stickyScrollController.ts
42
+ //
43
+ // State has two key values:
44
+ // • startLineNumbers[] — the N sticky lines to show (outermost → innermost)
45
+ // • lastLineRelativePosition — NEGATIVE in VSCode; how many px the LAST
46
+ // (innermost) line has been pushed UP by the
47
+ // approaching section break. Range (−lineHeight, 0].
48
+ //
49
+ // DOM layout (widget):
50
+ // rootDomNode (.sticky-widget) overflow:hidden; height set per-frame
51
+ // sticky-widget-lines the rows, absolutely positioned
52
+ // row × N each row has an explicit `top`
53
+ // ─────────────────────────────────────────────────────────────────────────────
54
+ class StickyScrollPlugin {
55
+ get lineHeight() {
56
+ return Math.round(this.view.defaultLineHeight) || 16;
57
+ }
58
+ constructor(view) {
59
+ this.cache = new Map();
60
+ this.rafHandle = null;
61
+ this.observer = null;
62
+ this.gutterObserver = null;
63
+ this.gutterMutationObserver = null;
64
+ this.gutterMetrics = { totalWidth: 0, columns: [] };
65
+ this.currentHeight = 0;
66
+ this.lastLineKey = "";
67
+ this.onScroll = () => this.request();
68
+ this.view = view;
69
+ this.config = view.state.facet(stickyScrollFacet);
70
+ // Outer container — clips content, fixed at top of editor.
71
+ this.dom = document.createElement("div");
72
+ this.dom.className =
73
+ "cm-stickyscroll-container" +
74
+ (this.config.class ? ` ${this.config.class}` : "");
75
+ // Inner wrapper — holds the rows; each row is laid out in normal flow and
76
+ // the container clips at its bottom edge (VSCode slide-away).
77
+ this.inner = document.createElement("div");
78
+ this.inner.className = "cm-stickyscroll-inner";
79
+ this.dom.appendChild(this.inner);
80
+ view.dom.appendChild(this.dom);
81
+ view.scrollDOM.addEventListener("scroll", this.onScroll, { passive: true });
82
+ this.observer = new ResizeObserver(this.onScroll);
83
+ this.observer.observe(view.dom);
84
+ const gutterEl = view.dom.querySelector(".cm-gutters");
85
+ if (gutterEl) {
86
+ this.gutterObserver = new ResizeObserver(this.onScroll);
87
+ this.gutterObserver.observe(gutterEl);
88
+ this.gutterMutationObserver = new MutationObserver(() => {
89
+ this.clearCache();
90
+ this.request();
91
+ });
92
+ this.gutterMutationObserver.observe(gutterEl, { childList: true });
93
+ }
94
+ this.request();
95
+ }
96
+ clearCache() {
97
+ this.cache.clear();
98
+ this.lastLineKey = "";
99
+ }
100
+ update(update) {
101
+ const configChanged = update.state.facet(stickyScrollFacet) !== update.startState.facet(stickyScrollFacet);
102
+ this.config = update.state.facet(stickyScrollFacet);
103
+ const contentChanged = configChanged ||
104
+ update.docChanged ||
105
+ syntaxTree(update.startState) !== syntaxTree(update.state);
106
+ if (contentChanged) {
107
+ this.clearCache();
108
+ }
109
+ if (contentChanged ||
110
+ update.viewportChanged ||
111
+ update.geometryChanged ||
112
+ update.selectionSet ||
113
+ update.startState.facet(language) !== update.state.facet(language)) {
114
+ this.request();
115
+ }
116
+ }
117
+ destroy() {
118
+ var _a, _b;
119
+ if (this.rafHandle !== null)
120
+ cancelAnimationFrame(this.rafHandle);
121
+ this.view.scrollDOM.removeEventListener("scroll", this.onScroll);
122
+ (_a = this.observer) === null || _a === void 0 ? void 0 : _a.disconnect();
123
+ (_b = this.gutterObserver) === null || _b === void 0 ? void 0 : _b.disconnect();
124
+ this.dom.remove();
125
+ this.cache.clear();
126
+ }
127
+ request() {
128
+ if (this.rafHandle !== null)
129
+ return;
130
+ this.rafHandle = requestAnimationFrame(() => {
131
+ try {
132
+ this.render();
133
+ }
134
+ catch (e) {
135
+ console.warn("[codemirror-stickyscroll]", e);
136
+ }
137
+ this.rafHandle = null;
138
+ });
139
+ }
140
+ // ─────────────────────────────────────────────────────────────────────────
141
+ // Main render — called every animation frame on scroll
142
+ // ─────────────────────────────────────────────────────────────────────────
143
+ render() {
144
+ const view = this.view;
145
+ // ── Measure ─────────────────────────────────────────────────────────────
146
+ this.gutterMetrics = measureGutters(view);
147
+ // ── Background (match active theme) ─────────────────────────────────────
148
+ // Always opaque (Monaco's sticky widget never lets the code show through).
149
+ this.dom.style.backgroundColor = this.resolveBackground();
150
+ // ── Compute sticky context ───────────────────────────────────────────────
151
+ const lines = getStickyContext(view, this.config);
152
+ if (!lines.length) {
153
+ this.hide();
154
+ return;
155
+ }
156
+ // ── Rebuild rows when line-set changes ───────────────────────────────────
157
+ const lineKey = lines.map((l) => l.lineNumber).join("|");
158
+ if (lineKey !== this.lastLineKey) {
159
+ this.lastLineKey = lineKey;
160
+ this.buildRows(lines);
161
+ }
162
+ const lh = this.lineHeight;
163
+ const totalLines = lines.length;
164
+ // ── Apply layout math ────────────────────────────────────────────────────
165
+ //
166
+ // The container clips via overflow:hidden; we never translate the whole
167
+ // stack — that would make the OUTERMOST line disappear first, unlike VSCode.
168
+ const containerHeight = totalLines * lh;
169
+ this.currentHeight = containerHeight;
170
+ this.dom.style.height = `${containerHeight}px`;
171
+ this.dom.style.display = "";
172
+ // Apply per-frame row attributes (gutter metrics, scrollLeft and highlight).
173
+ this.updateRowAttrs(lines);
174
+ this.dom.dir = view.textDirection === Direction.RTL ? "rtl" : "ltr";
175
+ this.inner.style.lineHeight = `${lh}px`;
176
+ }
177
+ // ─────────────────────────────────────────────────────────────────────────
178
+ // DOM management
179
+ // ─────────────────────────────────────────────────────────────────────────
180
+ hide() {
181
+ this.dom.style.display = "none";
182
+ this.currentHeight = 0;
183
+ // Do NOT clear this.inner — keep rows for fast reuse on next show.
184
+ }
185
+ /**
186
+ * Rebuild all row DOM nodes (called only when line numbers change).
187
+ * We clear the inner wrapper and build fresh — no stale DOM element issues.
188
+ */
189
+ buildRows(lines) {
190
+ this.inner.textContent = ""; // Remove all children cleanly
191
+ for (const line of lines) {
192
+ this.inner.appendChild(this.makeRow(line));
193
+ }
194
+ }
195
+ /**
196
+ * Update per-frame attributes WITHOUT rebuilding DOM.
197
+ * Called every frame when the line-set is unchanged.
198
+ */
199
+ updateRowAttrs(lines) {
200
+ const gm = this.gutterMetrics;
201
+ const rowEls = this.inner.children;
202
+ for (let i = 0; i < lines.length && i < rowEls.length; i++) {
203
+ const line = lines[i];
204
+ const row = rowEls[i];
205
+ this.setHeight(row);
206
+ this.setClass(row, line);
207
+ const gutter = row.firstElementChild;
208
+ this.setWidth(row, gutter);
209
+ this.setHeight(gutter);
210
+ const cols = gutter.children;
211
+ for (let c = 0; c < gm.columns.length && c < cols.length; c++) {
212
+ cols[c].style.width = `${gm.columns[c].width}px`;
213
+ }
214
+ const code = row.lastElementChild;
215
+ this.setTransform(code);
216
+ }
217
+ }
218
+ /**
219
+ * Build a single sticky row element.
220
+ *
221
+ * The gutter overlay mirrors the real editor gutter column-by-column:
222
+ * .cm-stickyscroll-gutter (position:absolute, width = total gutter width)
223
+ */
224
+ makeRow(line) {
225
+ const view = this.view;
226
+ const gm = this.gutterMetrics;
227
+ // Row
228
+ const row = document.createElement("div");
229
+ row.className = "cm-stickyscroll-line";
230
+ this.setClass(row, line);
231
+ row.setAttribute("role", "button");
232
+ row.setAttribute("tabindex", "0");
233
+ row.setAttribute("aria-label", `Go to line ${line.lineNumber}`);
234
+ this.setHeight(row);
235
+ // Gutter overlay container
236
+ const gutter = document.createElement("div");
237
+ gutter.className = "cm-stickyscroll-gutter";
238
+ this.setWidth(row, gutter);
239
+ this.setHeight(gutter);
240
+ if (gm.columns.length) {
241
+ gm.columns.forEach((colMetrics) => {
242
+ const col = document.createElement("div");
243
+ // Reuse the exact same classes so any custom user CSS (like Notron's .cm-lineNumbers .cm-gutterElement) matches!
244
+ col.className = colMetrics.className;
245
+ col.style.width = `${colMetrics.width}px`;
246
+ if (colMetrics.className.includes("cm-lineNumbers")) {
247
+ // Wrap in cm-gutterElement to perfectly match the DOM structure of CM6's line numbers.
248
+ const el = document.createElement("div");
249
+ el.className = "cm-gutterElement";
250
+ el.textContent = String(line.lineNumber);
251
+ col.appendChild(el);
252
+ }
253
+ gutter.appendChild(col);
254
+ });
255
+ }
256
+ // Code span
257
+ const code = renderLineCode(view, line, this.cache);
258
+ this.setTransform(code);
259
+ row.appendChild(gutter);
260
+ row.appendChild(code);
261
+ // Click / keyboard
262
+ const jump = (e) => {
263
+ e.preventDefault();
264
+ e.stopPropagation();
265
+ view.dispatch({
266
+ selection: { anchor: line.from },
267
+ effects: EditorView.scrollIntoView(line.from, {
268
+ y: "start",
269
+ yMargin: this.currentHeight + 4,
270
+ }),
271
+ });
272
+ view.focus();
273
+ };
274
+ row.addEventListener("click", jump);
275
+ row.addEventListener("keydown", (ev) => {
276
+ if (ev.key === "Enter" || ev.key === " ")
277
+ jump(ev);
278
+ });
279
+ return row;
280
+ }
281
+ setClass(row, line) {
282
+ const head = this.view.state.selection.main.head;
283
+ row.classList.toggle("cm-stickyscroll-current", head >= line.nodeFrom && head <= line.nodeTo);
284
+ }
285
+ setWidth(row, gutter) {
286
+ const gw = this.gutterMetrics.totalWidth;
287
+ row.style.paddingInlineStart = `${gw}px`;
288
+ gutter.style.width = `${gw}px`;
289
+ }
290
+ setHeight(ele) {
291
+ const lh = this.lineHeight;
292
+ ele.style.height = `${lh}px`;
293
+ }
294
+ setTransform(code) {
295
+ const scrollX = this.view.scrollDOM.scrollLeft;
296
+ code.style.transform = scrollX > 0 ? `translateX(${-scrollX}px)` : "";
297
+ }
298
+ /**
299
+ * Resolve the sticky bar's background to a GUARANTEED-opaque color that
300
+ * matches the editor's effective backdrop.
301
+ *
302
+ * 1. Walk the real ancestor chain (contentDOM → … → <html>) and return the
303
+ * first opaque color, skipping fully transparent AND semi-transparent
304
+ * layers (a semi-transparent ancestor is only used as a last resort).
305
+ * 2. Fall back to a dark/light hex default.
306
+ *
307
+ * This makes it impossible for the sticky bar to end up see-through, exactly
308
+ * like Monaco's sticky widget which always paints a solid background.
309
+ */
310
+ resolveBackground() {
311
+ const detected = this.detectBg();
312
+ if (detected)
313
+ return detected;
314
+ const isDark = this.view.dom.classList.contains("cm-dark");
315
+ return isDark ? "#1e1e1e" : "#fff";
316
+ }
317
+ detectBg() {
318
+ let semiTransparent = null;
319
+ let el = this.view.contentDOM;
320
+ while (el) {
321
+ const bg = getComputedStyle(el).backgroundColor;
322
+ const alpha = alphaOf(bg);
323
+ if (alpha > 0) {
324
+ if (alpha >= 1)
325
+ return bg;
326
+ semiTransparent = semiTransparent || bg;
327
+ }
328
+ el = el.parentElement;
329
+ }
330
+ return semiTransparent;
331
+ }
332
+ }
333
+ export const scrollStickyPlugin = ViewPlugin.fromClass(StickyScrollPlugin, {});
@@ -0,0 +1,22 @@
1
+ import type { EditorView } from "@codemirror/view";
2
+ import type { StickyLine } from "./types";
3
+ /** Cache of rendered code elements keyed by opening line number. */
4
+ export type RowCache = Map<number, {
5
+ text: string;
6
+ el: HTMLDivElement;
7
+ }>;
8
+ /**
9
+ * Build (or return cached) a `<div class="cm-stickyscroll-code">` element
10
+ * containing the syntax-highlighted code for one opening line (§4.4 / §4.6b).
11
+ *
12
+ * Priority:
13
+ * 1. Deep-clone the live CM6 `.cm-line` element when it is in the viewport —
14
+ * token classes (`.tok-*`) are reused verbatim, guaranteed theme-accurate.
15
+ * 2. Re-highlight via the consumer's active `HighlightStyle`(s) using
16
+ * `highlightingFor()` — never our own color palette.
17
+ * 3. Plain-text fallback (leading whitespace/indentation preserved).
18
+ *
19
+ * Results are cached by `(lineNumber, text)` so unchanged lines are free.
20
+ * The cache must be cleared externally when the syntax tree or config changes.
21
+ */
22
+ export declare function renderLineCode(view: EditorView, line: StickyLine, cache: RowCache): HTMLDivElement;
package/dist/render.js ADDED
@@ -0,0 +1,140 @@
1
+ import { highlightingFor, language } from "@codemirror/language";
2
+ import { highlightTree } from "@lezer/highlight";
3
+ import { syntaxTree } from "@codemirror/language";
4
+ // ---------------------------------------------------------------------------
5
+ // Internal: consumer-theme-aware Highlighter
6
+ // ---------------------------------------------------------------------------
7
+ /**
8
+ * Resolves highlight classes from the *active* styles in the consumer's
9
+ * EditorState (§4.4) — not from any palette bundled with this package.
10
+ */
11
+ class StateHighlighter {
12
+ constructor(state, topNodeType) {
13
+ this.state = state;
14
+ this.topNodeType = topNodeType;
15
+ }
16
+ style(tags) {
17
+ return highlightingFor(this.state, tags, this.topNodeType);
18
+ }
19
+ scope() {
20
+ return true;
21
+ }
22
+ }
23
+ // ---------------------------------------------------------------------------
24
+ // Internal: find the live `.cm-line` DOM element for a document line
25
+ // ---------------------------------------------------------------------------
26
+ function findRenderedLineElement(view, lineNumber) {
27
+ const lineFrom = view.state.doc.line(lineNumber).from;
28
+ // Only lines that are actually rendered in the DOM can be cloned. Anything
29
+ // outside `view.viewport` (visible area + render margin) must fall back to
30
+ // re-highlighting, never to a position-clamped lookup.
31
+ if (lineFrom < view.viewport.from || lineFrom > view.viewport.to)
32
+ return null;
33
+ // `domAtPos` maps a document position to its exact DOM node, so it is immune
34
+ // to index misalignment between `viewportLineBlocks` and `querySelectorAll`.
35
+ // (Block widgets, placeholder lines and render-margin lines make those two
36
+ // lists diverge, which previously caused wrong — blank / comment / `}` —
37
+ // line text to be cloned into the sticky bar.)
38
+ let dom;
39
+ try {
40
+ dom = view.domAtPos(lineFrom, 1);
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ let el = dom.node instanceof HTMLElement ? dom.node : dom.node.parentElement;
46
+ while (el && !el.classList.contains("cm-line"))
47
+ el = el.parentElement;
48
+ return el;
49
+ }
50
+ // ---------------------------------------------------------------------------
51
+ // Public: renderLineCode
52
+ // ---------------------------------------------------------------------------
53
+ /**
54
+ * Build (or return cached) a `<div class="cm-stickyscroll-code">` element
55
+ * containing the syntax-highlighted code for one opening line (§4.4 / §4.6b).
56
+ *
57
+ * Priority:
58
+ * 1. Deep-clone the live CM6 `.cm-line` element when it is in the viewport —
59
+ * token classes (`.tok-*`) are reused verbatim, guaranteed theme-accurate.
60
+ * 2. Re-highlight via the consumer's active `HighlightStyle`(s) using
61
+ * `highlightingFor()` — never our own color palette.
62
+ * 3. Plain-text fallback (leading whitespace/indentation preserved).
63
+ *
64
+ * Results are cached by `(lineNumber, text)` so unchanged lines are free.
65
+ * The cache must be cleared externally when the syntax tree or config changes.
66
+ */
67
+ export function renderLineCode(view, line, cache) {
68
+ // Cache hit.
69
+ const hit = cache.get(line.lineNumber);
70
+ if (hit && hit.text === line.text)
71
+ return hit.el;
72
+ const makeSpan = (child) => {
73
+ const s = document.createElement("div");
74
+ s.className = "cm-stickyscroll-code";
75
+ if (child)
76
+ s.appendChild(child);
77
+ else
78
+ s.textContent = line.text; // plain text, indentation preserved
79
+ return s;
80
+ };
81
+ // ── Strategy 1: clone the live viewport DOM line ─────────────────────────
82
+ const rendered = findRenderedLineElement(view, line.lineNumber);
83
+ // Ensure we don't clone empty virtual DOM placeholders that CM uses during fast scrolls.
84
+ if (rendered && rendered.textContent !== "") {
85
+ const frag = document.createDocumentFragment();
86
+ for (const child of rendered.childNodes) {
87
+ frag.appendChild(child.cloneNode(true));
88
+ }
89
+ const el = makeSpan(frag);
90
+ cache.set(line.lineNumber, { text: line.text, el });
91
+ return el;
92
+ }
93
+ // ── Strategy 2: re-highlight from the consumer's active HighlightStyle(s) ─
94
+ const lang = view.state.facet(language);
95
+ if (lang) {
96
+ try {
97
+ const tree = syntaxTree(view.state);
98
+ const topType = tree.type;
99
+ const highlighters = [new StateHighlighter(view.state, topType)];
100
+ const out = document.createElement("div");
101
+ out.className = "cm-stickyscroll-code";
102
+ let last = line.from;
103
+ highlightTree(tree, highlighters, (from, to, cls) => {
104
+ const start = Math.max(last, from);
105
+ const end = Math.min(line.to, to);
106
+ if (start >= end)
107
+ return;
108
+ if (start > last) {
109
+ out.appendChild(document.createTextNode(line.text.slice(last - line.from, start - line.from)));
110
+ }
111
+ if (cls) {
112
+ const tok = document.createElement("span");
113
+ tok.className = cls;
114
+ tok.textContent = line.text.slice(start - line.from, end - line.from);
115
+ out.appendChild(tok);
116
+ }
117
+ else {
118
+ out.appendChild(document.createTextNode(line.text.slice(start - line.from, end - line.from)));
119
+ }
120
+ last = end;
121
+ }, line.from, line.to);
122
+ // Trailing unstyled text.
123
+ if (last < line.to) {
124
+ out.appendChild(document.createTextNode(line.text.slice(last - line.from)));
125
+ }
126
+ // Only cache when we produced actual content.
127
+ if (out.childNodes.length > 0) {
128
+ cache.set(line.lineNumber, { text: line.text, el: out });
129
+ return out;
130
+ }
131
+ }
132
+ catch {
133
+ // Fall through to plain text.
134
+ }
135
+ }
136
+ // ── Strategy 3: plain text ────────────────────────────────────────────────
137
+ const el = makeSpan();
138
+ cache.set(line.lineNumber, { text: line.text, el });
139
+ return el;
140
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Cosmetic-only theme for the sticky bar.
3
+ *
4
+ * ALL sizing (height, lineHeight, padding, overflow, position, display,
5
+ * transform) is set via inline style by plugin.ts — matching Monaco's exact
6
+ * approach of updating styles imperatively on every scroll event.
7
+ *
8
+ * This file only provides:
9
+ * 1. Background color fallback (JS overrides immediately with actual editor bg)
10
+ * 2. Current-scope highlight
11
+ * 3. Gutter cosmetics (color, opacity)
12
+ * 4. Dark-mode override for background fallback
13
+ *
14
+ * ⛔ Do NOT add: height, lineHeight, overflow, display, padding, margin,
15
+ * position, transform to .cm-stickyscroll-line or .cm-stickyscroll-inner.
16
+ * Plugin.ts owns those — any CSS here will conflict.
17
+ * ⛔ Do NOT add syntaxHighlighting — token colors come from the consumer's theme.
18
+ */
19
+ export declare const stickyScrollBaseTheme: import("@codemirror/state").Extension;
package/dist/theme.js ADDED
@@ -0,0 +1,73 @@
1
+ import { EditorView } from "@codemirror/view";
2
+ /**
3
+ * Cosmetic-only theme for the sticky bar.
4
+ *
5
+ * ALL sizing (height, lineHeight, padding, overflow, position, display,
6
+ * transform) is set via inline style by plugin.ts — matching Monaco's exact
7
+ * approach of updating styles imperatively on every scroll event.
8
+ *
9
+ * This file only provides:
10
+ * 1. Background color fallback (JS overrides immediately with actual editor bg)
11
+ * 2. Current-scope highlight
12
+ * 3. Gutter cosmetics (color, opacity)
13
+ * 4. Dark-mode override for background fallback
14
+ *
15
+ * ⛔ Do NOT add: height, lineHeight, overflow, display, padding, margin,
16
+ * position, transform to .cm-stickyscroll-line or .cm-stickyscroll-inner.
17
+ * Plugin.ts owns those — any CSS here will conflict.
18
+ * ⛔ Do NOT add syntaxHighlighting — token colors come from the consumer's theme.
19
+ */
20
+ export const stickyScrollBaseTheme = EditorView.baseTheme({
21
+ // Container background fallback — JS overrides with exact editor color.
22
+ ".cm-stickyscroll-container": {
23
+ position: "absolute",
24
+ top: 0,
25
+ left: 0,
26
+ right: 0,
27
+ zIndex: 10,
28
+ overflow: "hidden",
29
+ boxSizing: "border-box",
30
+ fontFamily: "monospace",
31
+ tabSize: 4,
32
+ borderBottom: "1px solid rgba(128,128,128,.2)",
33
+ boxShadow: "0 2px 4px var(--cm-stickyscroll-shadow)",
34
+ },
35
+ "&light .cm-stickyscroll-container": {
36
+ "--cm-stickyscroll-shadow": "rgba(0,0,0,.12)",
37
+ },
38
+ "&dark .cm-stickyscroll-container": {
39
+ "--cm-stickyscroll-shadow": "rgba(255,255,255,.12)",
40
+ },
41
+ ".cm-stickyscroll-line": {
42
+ position: "relative",
43
+ overflow: "hidden",
44
+ whiteSpace: "pre",
45
+ boxSizing: "border-box",
46
+ cursor: "pointer",
47
+ // Current scope indicator (left accent bar — same as VSCode).
48
+ "&.cm-stickyscroll-current": {
49
+ backgroundColor: "rgba(128,128,128,.07)",
50
+ boxShadow: "inset 3px 0 0 #4b9edd",
51
+ },
52
+ "&:hover": {
53
+ backgroundColor: "rgba(128,128,128,.09)",
54
+ },
55
+ },
56
+ // Gutter cosmetics — position/size are inline from plugin.ts.
57
+ ".cm-stickyscroll-gutter": {
58
+ position: "absolute",
59
+ top: 0,
60
+ insetInlineStart: 0,
61
+ zIndex: 1,
62
+ display: "flex",
63
+ alignItems: "stretch",
64
+ boxSizing: "border-box",
65
+ WebkitUserSelect: "none",
66
+ userSelect: "none",
67
+ borderInlineEnd: "1px solid rgba(128,128,128,.18)",
68
+ opacity: 0.55,
69
+ },
70
+ ".cm-stickyscroll-code": {
71
+ padding: "0 2px 0 6px",
72
+ },
73
+ });
@@ -0,0 +1,21 @@
1
+ /**
2
+ * A single sticky breadcrumb row shown in the sticky bar.
3
+ *
4
+ * Each entry describes the *opening line* of a scope block (function, class,
5
+ * if/loop block, ...) whose opening line has scrolled out above the viewport
6
+ * while its closing line is still at/below the viewport.
7
+ */
8
+ export interface StickyLine {
9
+ /** 1-based line number of the opening line. */
10
+ lineNumber: number;
11
+ /** Position of the start of the opening line. Used for click-to-jump + selection. */
12
+ from: number;
13
+ /** Position of the end of the opening line. */
14
+ to: number;
15
+ /** Full text of the opening line (leading indentation preserved). */
16
+ text: string;
17
+ /** Start position of the whole foldable scope node (e.g. `function` keyword). */
18
+ nodeFrom: number;
19
+ /** End position of the whole foldable scope node (e.g. closing brace). */
20
+ nodeTo: number;
21
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@bhsd/codemirror-stickyscroll",
3
+ "version": "0.1.0",
4
+ "description": "VS Code / Monaco-style sticky scroll (sticky lines) extension for CodeMirror 6",
5
+ "keywords": [
6
+ "codemirror",
7
+ "sticky"
8
+ ],
9
+ "homepage": "https://github.com/bhsd-harry/stickyscroll#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/bhsd-harry/stickyscroll/issues"
12
+ },
13
+ "license": "MIT",
14
+ "contributors": [
15
+ "[Zulfazli (Fazelllyyy)](https://github.com/fazelllyyy)",
16
+ "Bhsd"
17
+ ],
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/bhsd-harry/codemirror-stickyscroll.git"
21
+ },
22
+ "type": "module",
23
+ "files": [
24
+ "/dist/"
25
+ ],
26
+ "main": "dist/index.js",
27
+ "types": "dist/index.d.ts",
28
+ "sideEffects": false,
29
+ "scripts": {
30
+ "prepublishOnly": "npm run build",
31
+ "lint:ts": "tsc --noEmit && eslint --cache .",
32
+ "lint:md": "markdownlint-cli2 '**/*.md'",
33
+ "lint:css": "stylelint --cache '**/*.css'",
34
+ "lint": "npm run lint:ts && npm run lint:css && npm run lint:md",
35
+ "build": "tsc && eslint --no-inline-config --no-config-lookup -c eslint.dist.js dist/*.js"
36
+ },
37
+ "peerDependencies": {
38
+ "@codemirror/language": "^6.3.0",
39
+ "@codemirror/state": "^6.0.0",
40
+ "@codemirror/view": "^6.0.0"
41
+ },
42
+ "devDependencies": {
43
+ "@bhsd/code-standard": "^4.1.0",
44
+ "@codemirror/language": "^6.12.4",
45
+ "@codemirror/state": "^6.7.1",
46
+ "@codemirror/view": "^6.43.9",
47
+ "eslint": "^10.9.1",
48
+ "markdownlint-cli2": "^0.23.2",
49
+ "stylelint": "^17.14.1",
50
+ "typescript": "^6.0.3"
51
+ }
52
+ }