@liminis/editor 0.1.0 → 0.2.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 (35) hide show
  1. package/README.md +126 -14
  2. package/dist/annotations/types.d.ts +16 -0
  3. package/dist/app/App.d.ts +1 -1
  4. package/dist/app/App.js +3 -3
  5. package/dist/app/editor/AnchorScrollPlugin.js +2 -34
  6. package/dist/app/editor/AnnotationSurface.js +2 -1
  7. package/dist/app/editor/CorrectionPanelPlugin.js +10 -10
  8. package/dist/app/editor/DocumentOutline.d.ts +17 -0
  9. package/dist/app/editor/DocumentOutline.js +32 -0
  10. package/dist/app/editor/DragHandlePlugin.js +1 -1
  11. package/dist/app/editor/Editor.d.ts +10 -1
  12. package/dist/app/editor/Editor.js +3 -2
  13. package/dist/app/editor/OutlinePlugin.d.ts +14 -0
  14. package/dist/app/editor/OutlinePlugin.js +133 -0
  15. package/dist/app/editor/SelectionContextMenuPlugin.js +4 -4
  16. package/dist/app/editor/annotation-marks.d.ts +20 -0
  17. package/dist/app/editor/annotation-marks.js +40 -0
  18. package/dist/app/editor/documentOutlineHandle.d.ts +87 -0
  19. package/dist/app/editor/documentOutlineHandle.js +86 -0
  20. package/dist/app/editor/nodes/C4Component.js +6 -6
  21. package/dist/app/editor/nodes/DiagramContextMenu.js +5 -5
  22. package/dist/app/editor/scrollContainer.d.ts +18 -0
  23. package/dist/app/editor/scrollContainer.js +47 -0
  24. package/dist/index.d.ts +4 -0
  25. package/dist/index.js +6 -0
  26. package/dist/markdown/stringify.js +65 -0
  27. package/dist/styles.css +342 -221
  28. package/docs/decisions/adr-078.md +37 -2
  29. package/docs/decisions/adr-085.md +166 -0
  30. package/docs/decisions/adr-086.md +147 -0
  31. package/docs/decisions/adr-087.md +216 -0
  32. package/docs/decisions/adr-088.md +136 -0
  33. package/docs/decisions/adr-089.md +121 -0
  34. package/docs/editor-api.md +53 -1
  35. package/package.json +23 -46
package/README.md CHANGED
@@ -194,25 +194,137 @@ Note the contrast with the failure above: forgetting the *import* fails
194
194
  silently, while adding it without a declaration fails loudly at compile time.
195
195
  Neither is a package defect, but only one of them tells you what is wrong.
196
196
 
197
- ### If you use Tailwind
197
+ ### Theming: CSS custom properties
198
+
199
+ The editor's colors, borders and a few layout metrics are all CSS custom
200
+ properties under a single package-owned vocabulary, `--liminis-editor-*`.
201
+ Every one of them resolves with no host configuration at all — the table
202
+ below exists so you can *override* a palette, not so you can complete one.
203
+
204
+ **Previous name** is the pre-`0.2.0` name this property replaced — the exact
205
+ name to look for if you're migrating a host that still overrides the old
206
+ vocabulary (`—` for a property that was never renamed). **Controls** is a
207
+ short description of what the token actually affects. **Kind** distinguishes
208
+ tokens that only affect appearance (`Cosmetic`) from the handful that also
209
+ affect layout (`Structural` — heading indents and the base font). **Has a
210
+ default** marks tokens with a `--liminis-editor-*` `:root`/`.dark`
211
+ declaration in `styles.css` directly; every row below reads "No (inline
212
+ fallback only)" because each token instead resolves through a fallback chain
213
+ — `--liminis-editor-x` first, then its previous name (which still carries the
214
+ real `:root`/`.dark` default), so a host supplying only the old name keeps
215
+ theming unchanged. See "Migrating from the old names" below.
216
+
217
+ This table is generated from `src/` by `node scripts/generate-theming-docs.mjs`
218
+ and checked for staleness in CI (`tests/theming-contract.test.ts`) — a `var(--x)`
219
+ added to the source without regenerating this block fails the build. Do not
220
+ hand-edit the rows between the markers below. See ADR-085 for how the
221
+ generation and drift guard work, and ADR-087 for the rename and its
222
+ compatibility design.
223
+
224
+ <!-- theming-tokens:start -->
225
+ | Custom property | Previous name | Controls | Kind | Has a default |
226
+ | --- | --- | --- | --- | --- |
227
+ | `--liminis-editor-background` | `--vscode-background` | Base background color of the editor surface and its popovers/menus. | Cosmetic | No (inline fallback only) |
228
+ | `--liminis-editor-bold-color` | `--slashmd-bold-color` | Text color of bold (`**text**`) markdown spans. | Cosmetic | No (inline fallback only) |
229
+ | `--liminis-editor-border` | `--vscode-border` | Default border color used throughout the editor chrome. | Cosmetic | No (inline fallback only) |
230
+ | `--liminis-editor-button-background` | `--vscode-button-background` | Background of primary action buttons (e.g. the correction panel's "Apply" button). | Cosmetic | No (inline fallback only) |
231
+ | `--liminis-editor-button-foreground` | `--vscode-button-foreground` | Text color of primary action buttons. | Cosmetic | No (inline fallback only) |
232
+ | `--liminis-editor-callout-caution-bg` | `--slashmd-callout-caution-bg` | Background of "caution" callout blocks. | Cosmetic | No (inline fallback only) |
233
+ | `--liminis-editor-callout-caution-border` | `--slashmd-callout-caution-border` | Left border accent of "caution" callout blocks. | Cosmetic | No (inline fallback only) |
234
+ | `--liminis-editor-callout-important-bg` | `--slashmd-callout-important-bg` | Background of "important" callout blocks. | Cosmetic | No (inline fallback only) |
235
+ | `--liminis-editor-callout-important-border` | `--slashmd-callout-important-border` | Left border accent of "important" callout blocks. | Cosmetic | No (inline fallback only) |
236
+ | `--liminis-editor-callout-note-bg` | `--slashmd-callout-note-bg` | Background of "note" callout blocks. | Cosmetic | No (inline fallback only) |
237
+ | `--liminis-editor-callout-note-border` | `--slashmd-callout-note-border` | Left border accent of "note" callout blocks. | Cosmetic | No (inline fallback only) |
238
+ | `--liminis-editor-callout-tip-bg` | `--slashmd-callout-tip-bg` | Background of "tip" callout blocks. | Cosmetic | No (inline fallback only) |
239
+ | `--liminis-editor-callout-tip-border` | `--slashmd-callout-tip-border` | Left border accent of "tip" callout blocks. | Cosmetic | No (inline fallback only) |
240
+ | `--liminis-editor-callout-warning-bg` | `--slashmd-callout-warning-bg` | Background of "warning" callout blocks. | Cosmetic | No (inline fallback only) |
241
+ | `--liminis-editor-callout-warning-border` | `--slashmd-callout-warning-border` | Left border accent of "warning" callout blocks. | Cosmetic | No (inline fallback only) |
242
+ | `--liminis-editor-checkbox-border` | `--checkbox-border` | Border color of unchecked task-list checkboxes. | Cosmetic | No (inline fallback only) |
243
+ | `--liminis-editor-code-bg` | `--vscode-code-bg` | Background of inline code spans and fenced code blocks. | Cosmetic | No (inline fallback only) |
244
+ | `--liminis-editor-errorForeground` | `--vscode-errorForeground` | Text color for error and validation messages. | Cosmetic | No (inline fallback only) |
245
+ | `--liminis-editor-external-link` | `--vscode-external-link` | Text color of links that point outside the document. | Cosmetic | No (inline fallback only) |
246
+ | `--liminis-editor-focus-border` | `--vscode-focus-border` | Border color of the toolbar link-input field when focused. | Cosmetic | No (inline fallback only) |
247
+ | `--liminis-editor-focusBorder` | `--vscode-focusBorder` | Color of the drag-and-drop position indicator while reordering blocks. | Cosmetic | No (inline fallback only) |
248
+ | `--liminis-editor-font-family` | `--vscode-font-family` | Base font family for the editor content. | Structural | No (inline fallback only) |
249
+ | `--liminis-editor-font-size` | `--vscode-font-size` | Base font size for the editor content. | Structural | No (inline fallback only) |
250
+ | `--liminis-editor-foreground` | `--vscode-foreground` | Default text color throughout the editor. | Cosmetic | No (inline fallback only) |
251
+ | `--liminis-editor-foreground-muted` | `--vscode-foreground-muted` | Hover border color for the frontmatter tray's raw-view toggle. | Cosmetic | No (inline fallback only) |
252
+ | `--liminis-editor-h1-color` | `--slashmd-h1-color` | Text color of level-1 (`#`) headings. | Cosmetic | No (inline fallback only) |
253
+ | `--liminis-editor-h1-indent` | `--slashmd-h1-indent` | Left margin of level-1 (`#`) headings. | Structural | No (inline fallback only) |
254
+ | `--liminis-editor-h2-color` | `--slashmd-h2-color` | Text color of level-2 (`##`) headings. | Cosmetic | No (inline fallback only) |
255
+ | `--liminis-editor-h2-indent` | `--slashmd-h2-indent` | Left margin of level-2 (`##`) headings. | Structural | No (inline fallback only) |
256
+ | `--liminis-editor-h3-color` | `--slashmd-h3-color` | Text color of level-3 (`###`) headings. | Cosmetic | No (inline fallback only) |
257
+ | `--liminis-editor-h3-indent` | `--slashmd-h3-indent` | Left margin of level-3 (`###`) headings. | Structural | No (inline fallback only) |
258
+ | `--liminis-editor-h4-color` | `--slashmd-h4-color` | Text color of level-4 (`####`) headings. | Cosmetic | No (inline fallback only) |
259
+ | `--liminis-editor-h4-indent` | `--slashmd-h4-indent` | Left margin of level-4 (`####`) headings. | Structural | No (inline fallback only) |
260
+ | `--liminis-editor-h5-color` | `--slashmd-h5-color` | Text color of level-5 (`#####`) headings. | Cosmetic | No (inline fallback only) |
261
+ | `--liminis-editor-h5-indent` | `--slashmd-h5-indent` | Left margin of level-5 (`#####`) headings. | Structural | No (inline fallback only) |
262
+ | `--liminis-editor-input-bg` | `--vscode-input-bg` | Background of the toolbar link-input field. | Cosmetic | No (inline fallback only) |
263
+ | `--liminis-editor-inputValidation-errorBackground` | `--vscode-inputValidation-errorBackground` | Background of inline validation-error messages. | Cosmetic | No (inline fallback only) |
264
+ | `--liminis-editor-italic-color` | `--slashmd-italic-color` | Text color of italic (`*text*`) markdown spans. | Cosmetic | No (inline fallback only) |
265
+ | `--liminis-editor-link` | `--vscode-link` | Text color of in-document links. | Cosmetic | No (inline fallback only) |
266
+ | `--liminis-editor-menu-background` | `--vscode-menu-background` | Background of context menus (block, selection and correction menus). | Cosmetic | No (inline fallback only) |
267
+ | `--liminis-editor-menu-border` | `--vscode-menu-border` | Border color of context menus. | Cosmetic | No (inline fallback only) |
268
+ | `--liminis-editor-menu-foreground` | `--vscode-menu-foreground` | Text color of context menu items. | Cosmetic | No (inline fallback only) |
269
+ | `--liminis-editor-menu-selectionBackground` | `--vscode-menu-selectionBackground` | Background of a hovered/selected context menu item. | Cosmetic | No (inline fallback only) |
270
+ | `--liminis-editor-menu-separatorBackground` | `--vscode-menu-separatorBackground` | Color of separator lines inside context menus. | Cosmetic | No (inline fallback only) |
271
+ | `--liminis-editor-muted-100` | `--color-muted-100` | Background of the C4 diagram layout-toggle buttons when inactive. | Cosmetic | No (inline fallback only) |
272
+ | `--liminis-editor-muted-foreground` | `--color-muted-foreground` | Icon/text color of the C4 diagram layout-toggle buttons when inactive. | Cosmetic | No (inline fallback only) |
273
+ | `--liminis-editor-notificationsInfoIcon-foreground` | `--vscode-notificationsInfoIcon-foreground` | Color of informational icons in inline notifications. | Cosmetic | No (inline fallback only) |
274
+ | `--liminis-editor-primary` | `--color-primary` | Icon/text color of the C4 diagram layout-toggle buttons when active. | Cosmetic | No (inline fallback only) |
275
+ | `--liminis-editor-primary-100` | `--color-primary-100` | Background of the C4 diagram layout-toggle buttons when active. | Cosmetic | No (inline fallback only) |
276
+ | `--liminis-editor-selection` | `--vscode-selection` | Background color of selected/highlighted text. | Cosmetic | No (inline fallback only) |
277
+ | `--liminis-editor-token-comment` | `--slashmd-token-comment` | Syntax-highlight color for comments in fenced code blocks. | Cosmetic | No (inline fallback only) |
278
+ | `--liminis-editor-token-function` | `--slashmd-token-function` | Syntax-highlight color for function names in fenced code blocks. | Cosmetic | No (inline fallback only) |
279
+ | `--liminis-editor-token-keyword` | `--slashmd-token-keyword` | Syntax-highlight color for keywords in fenced code blocks. | Cosmetic | No (inline fallback only) |
280
+ | `--liminis-editor-token-operator` | `--slashmd-token-operator` | Syntax-highlight color for operators in fenced code blocks. | Cosmetic | No (inline fallback only) |
281
+ | `--liminis-editor-token-property` | `--slashmd-token-property` | Syntax-highlight color for object properties in fenced code blocks. | Cosmetic | No (inline fallback only) |
282
+ | `--liminis-editor-token-punctuation` | `--slashmd-token-punctuation` | Syntax-highlight color for punctuation in fenced code blocks. | Cosmetic | No (inline fallback only) |
283
+ | `--liminis-editor-token-selector` | `--slashmd-token-selector` | Syntax-highlight color for CSS selectors in fenced code blocks. | Cosmetic | No (inline fallback only) |
284
+ | `--liminis-editor-token-variable` | `--slashmd-token-variable` | Syntax-highlight color for variables in fenced code blocks. | Cosmetic | No (inline fallback only) |
285
+ | `--liminis-editor-toolbar-hoverBackground` | `--vscode-toolbar-hoverBackground` | Background of a toolbar button on hover. | Cosmetic | No (inline fallback only) |
286
+ <!-- theming-tokens:end -->
287
+
288
+ **A worked example.** To match a host's own palette, override a subset of
289
+ tokens after importing `styles.css` — no rebuild of the editor required:
198
290
 
199
- Some of the editor's markup also uses Tailwind utility classes. Tailwind v4
200
- generates utilities by scanning your source, and **its automatic source
201
- detection skips `node_modules`** — so without telling it where to look, those
202
- classes are simply absent. Again: silent, not an error.
291
+ ```css
292
+ @import '@liminis/editor/styles.css';
203
293
 
204
- Add an explicit `@source` for the package's built output in the CSS file where
205
- you import Tailwind:
294
+ :root {
295
+ --liminis-editor-foreground: #1a1a2e;
296
+ --liminis-editor-border: #d0d0e0;
297
+ --liminis-editor-link: #4b3fce;
298
+ --liminis-editor-code-bg: #f4f4fb;
299
+ }
206
300
 
207
- ```css
208
- @import "tailwindcss";
209
- @source "../node_modules/@liminis/editor/dist";
301
+ .dark {
302
+ --liminis-editor-foreground: #eaeaf5;
303
+ --liminis-editor-border: #3a3a55;
304
+ --liminis-editor-link: #9d90ff;
305
+ --liminis-editor-code-bg: #1e1e2e;
306
+ }
210
307
  ```
211
308
 
212
- Adjust the relative path to point at your installed copy. In a pnpm workspace
213
- the real path may be under `node_modules/.pnpm/` pointing `@source` at the
214
- symlinked path works, but verify a distinctive utility class actually appears in
215
- your generated CSS rather than assuming it.
309
+ Only the tokens you override need to change everything else keeps the
310
+ package default. `--liminis-editor-foreground` and `--liminis-editor-border`
311
+ are the two most widely referenced tokens, so they're usually the
312
+ highest-value place to start.
313
+
314
+ #### Migrating from the old names
315
+
316
+ Before `0.2.0`, these tokens were split across four inconsistent prefixes:
317
+ `--vscode-*`, `--slashmd-*`, `--color-*` and `--checkbox-*`. `0.2.0` renames
318
+ all 59 of them to `--liminis-editor-*`, but **a host that still only supplies
319
+ the old names keeps working unchanged** — every renamed token resolves
320
+ `--liminis-editor-x` first, then falls back to its specific previous name,
321
+ so nothing breaks silently on upgrade. The old prefixes are deprecated as of
322
+ `0.2.0` and will be removed in a future major release; migrate at your own
323
+ pace using the table above's **Previous name** column, which lists each
324
+ renamed property's exact old name — the mapping isn't always a mechanical
325
+ prefix swap (`--checkbox-border` became `--liminis-editor-checkbox-border`,
326
+ not `--liminis-editor-border`, to avoid colliding with `--vscode-border`'s
327
+ new name), so look it up rather than deriving it.
216
328
 
217
329
  ## Annotations
218
330
 
@@ -156,4 +156,20 @@ export interface AnnotationEditorHandle {
156
156
  removeMarksForAnnotation: (annotationId: string) => void;
157
157
  /** The current live range of every marked passage, keyed by annotation id. */
158
158
  collectLiveAnchorSnapshots: (markdownText: string) => Map<string, AnchorRange>;
159
+ /**
160
+ * Current on-screen geometry for existing, currently-rendered annotation
161
+ * marks (#73), keyed by annotation id — one `DOMRect` per constituent
162
+ * `MarkNode` (a multi-block annotation spans several sibling nodes), in
163
+ * document order. Computed fresh at call time from `getBoundingClientRect()`
164
+ * (viewport-relative, not scroll-container-relative); never cached.
165
+ *
166
+ * `annotationIds` omitted returns geometry for every annotation with at
167
+ * least one currently live mark. An id with no live mark (unknown, removed,
168
+ * or not yet rendered) is simply absent from the result — this never throws.
169
+ *
170
+ * Two annotation ids can legitimately report the same rect(s): overlapping
171
+ * annotations may share one `MarkNode`, and its rect is reported under each
172
+ * id it carries.
173
+ */
174
+ getMarkRects: (annotationIds?: readonly string[]) => Map<string, DOMRect[]>;
159
175
  }
package/dist/app/App.d.ts CHANGED
@@ -57,7 +57,7 @@ interface AppProps {
57
57
  annotationLogger?: {
58
58
  warn: (message: string, ...args: unknown[]) => void;
59
59
  };
60
- /** Override className on the root div (default: "min-h-screen p-0"). */
60
+ /** Override className on the root div (default: "editor-app-root"). */
61
61
  className?: string;
62
62
  }
63
63
  export declare function App({ editable, autoFocus, content: propContent, onChange: propOnChange, filePath, resolveLocalAsset, wikiLinkPromotion, onSelectionContextMenu, onSubstitutionDetected, sweepRef, annotationKinds, annotations, activeAnnotationId, scrollToAnnotation, onCreateAnnotation, onActivateAnnotation, annotationEditorHandleRef, annotationLogger, className }: AppProps): import("react").JSX.Element;
package/dist/app/App.js CHANGED
@@ -29,7 +29,7 @@ function computeMinimalEdits(oldText, newText) {
29
29
  newText: newTextContent,
30
30
  }];
31
31
  }
32
- export function App({ editable = true, autoFocus = false, content: propContent, onChange: propOnChange, filePath, resolveLocalAsset, wikiLinkPromotion, onSelectionContextMenu, onSubstitutionDetected, sweepRef, annotationKinds, annotations, activeAnnotationId, scrollToAnnotation, onCreateAnnotation, onActivateAnnotation, annotationEditorHandleRef, annotationLogger, className = 'min-h-screen p-0' }) {
32
+ export function App({ editable = true, autoFocus = false, content: propContent, onChange: propOnChange, filePath, resolveLocalAsset, wikiLinkPromotion, onSelectionContextMenu, onSubstitutionDetected, sweepRef, annotationKinds, annotations, activeAnnotationId, scrollToAnnotation, onCreateAnnotation, onActivateAnnotation, annotationEditorHandleRef, annotationLogger, className = 'editor-app-root' }) {
33
33
  const { bridge, logger } = useEditorHost();
34
34
  const log = useMemo(() => logger('slashmd/App'), [logger]);
35
35
  const { requestInit, applyTextEdits } = useHostMessages();
@@ -174,7 +174,7 @@ export function App({ editable = true, autoFocus = false, content: propContent,
174
174
  cursorStateRef.current = cursor;
175
175
  }, []);
176
176
  if (content === null) {
177
- return (_jsxs("div", { className: "flex items-center justify-center gap-3 min-h-screen text-[var(--vscode-foreground)] opacity-70", children: [_jsx("div", { className: "loading-spinner" }), _jsx("span", { children: "Loading document..." })] }));
177
+ return (_jsxs("div", { className: "editor-loading", children: [_jsx("div", { className: "loading-spinner" }), _jsx("span", { children: "Loading document..." })] }));
178
178
  }
179
- return (_jsxs("div", { className: className, children: [error && (_jsx("div", { className: "fixed top-0 left-0 right-0 px-4 py-2 bg-red-500 text-white text-center z-[1000]", role: "alert", children: error })), _jsx(Editor, { initialContent: content, autoFocus: autoFocus, contentVersion: contentVersion, cursorToRestoreRef: cursorStateRef, onChange: handleChange, onCursorChange: handleCursorChange, assetBaseUri: assetBaseUri, documentDirUri: documentDirUri, imagePathResolution: settings?.imagePathResolution ?? 'document', wikiLinkPromotion: wikiLinkPromotion, resolveLocalAsset: resolveLocalAsset, editable: editable, filePath: filePath, onSelectionContextMenu: onSelectionContextMenu, onSubstitutionDetected: onSubstitutionDetected, sweepRef: sweepRef, annotationKinds: annotationKinds, annotations: annotations, activeAnnotationId: activeAnnotationId, scrollToAnnotation: scrollToAnnotation, onCreateAnnotation: onCreateAnnotation, onActivateAnnotation: onActivateAnnotation, annotationEditorHandleRef: annotationEditorHandleRef, annotationLogger: annotationLogger })] }));
179
+ return (_jsxs("div", { className: className, children: [error && (_jsx("div", { className: "editor-error-banner", role: "alert", children: error })), _jsx(Editor, { initialContent: content, autoFocus: autoFocus, contentVersion: contentVersion, cursorToRestoreRef: cursorStateRef, onChange: handleChange, onCursorChange: handleCursorChange, assetBaseUri: assetBaseUri, documentDirUri: documentDirUri, imagePathResolution: settings?.imagePathResolution ?? 'document', wikiLinkPromotion: wikiLinkPromotion, resolveLocalAsset: resolveLocalAsset, editable: editable, filePath: filePath, onSelectionContextMenu: onSelectionContextMenu, onSubstitutionDetected: onSubstitutionDetected, sweepRef: sweepRef, annotationKinds: annotationKinds, annotations: annotations, activeAnnotationId: activeAnnotationId, scrollToAnnotation: scrollToAnnotation, onCreateAnnotation: onCreateAnnotation, onActivateAnnotation: onActivateAnnotation, annotationEditorHandleRef: annotationEditorHandleRef, annotationLogger: annotationLogger })] }));
180
180
  }
@@ -1,6 +1,7 @@
1
1
  import { useEffect } from 'react';
2
2
  import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
3
3
  import { useEditorHost } from '../../host/context.js';
4
+ import { scrollElementIntoView } from './scrollContainer.js';
4
5
  /**
5
6
  * Reduce text to a GitHub-style anchor slug so a link fragment matches a heading
6
7
  * regardless of punctuation the anchor drops. GitHub lowercases, removes
@@ -20,26 +21,6 @@ function normalizeForMatch(text) {
20
21
  .replace(/[\s-]+/g, '-') // spaces and dash runs → single hyphen
21
22
  .replace(/^-+|-+$/g, ''); // trim stray hyphens
22
23
  }
23
- /**
24
- * Find the scrollable container for a heading: the known editor scroll ids first,
25
- * then the nearest ancestor that actually scrolls (robust to host markup).
26
- */
27
- function scrollContainerFor(heading) {
28
- // Assumes the two known ids are never nested inside one another; neither
29
- // known host does. If that ever changes, the closer one should win instead.
30
- const byId = heading.closest('#editor-scroll-container') ||
31
- heading.closest('#editor-panel-scroll-container');
32
- if (byId)
33
- return byId;
34
- let el = heading.parentElement;
35
- while (el) {
36
- const overflowY = getComputedStyle(el).overflowY;
37
- if (/(auto|scroll)/.test(overflowY) && el.scrollHeight > el.clientHeight)
38
- return el;
39
- el = el.parentElement;
40
- }
41
- return null;
42
- }
43
24
  /**
44
25
  * Scroll the editor to the heading whose normalized text matches `anchor`.
45
26
  * Returns true if a matching heading was found and scrolled — false if not (yet)
@@ -63,20 +44,7 @@ function scrollToHeading(editor, anchor) {
63
44
  const heading = headings.find((h) => normalizeForMatch(h.textContent || '') === normalizedAnchor);
64
45
  if (!heading)
65
46
  return false;
66
- const container = scrollContainerFor(heading);
67
- if (container) {
68
- // rect-based offset is robust regardless of the heading's offsetParent;
69
- // leave a small margin so the heading sits near the top, not flush against it.
70
- const MARGIN = 16;
71
- const top = Math.max(0, heading.getBoundingClientRect().top -
72
- container.getBoundingClientRect().top +
73
- container.scrollTop -
74
- MARGIN);
75
- container.scrollTo({ top, behavior: 'smooth' });
76
- }
77
- else {
78
- heading.scrollIntoView({ behavior: 'smooth', block: 'start' });
79
- }
47
+ scrollElementIntoView(heading);
80
48
  return true;
81
49
  }
82
50
  /**
@@ -2,7 +2,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
2
2
  import { useEffect, useMemo, useRef } from 'react';
3
3
  import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
4
4
  import { deriveMarkerTargets, } from '../../annotations/types.js';
5
- import { collectLiveAnchorSnapshots, placeMarksForAnchors, removeMarksForAnnotation, removeMarksForAnnotations, } from './annotation-marks.js';
5
+ import { collectLiveAnchorSnapshots, getMarkRects, placeMarksForAnchors, removeMarksForAnnotation, removeMarksForAnnotations, } from './annotation-marks.js';
6
6
  import { AnnotationPlugin } from './AnnotationPlugin.js';
7
7
  import { AnnotationMarkerPlugin } from './AnnotationMarkerPlugin.js';
8
8
  import { registerMarkOverlapResolver } from './mark-overlap-resolver.js';
@@ -84,6 +84,7 @@ function AnnotationEditorHandlePlugin({ handleRef, wikiLinkPromotion, }) {
84
84
  handleRef.current = {
85
85
  removeMarksForAnnotation: (id) => removeMarksForAnnotation(editor, id),
86
86
  collectLiveAnchorSnapshots: (markdownText) => collectLiveAnchorSnapshots(editor, markdownText, { wikiLinkPromotion }),
87
+ getMarkRects: (annotationIds) => getMarkRects(editor, annotationIds),
87
88
  };
88
89
  return () => {
89
90
  handleRef.current = null;
@@ -29,10 +29,10 @@ const panelStyle = () => {
29
29
  padding: '12px',
30
30
  minWidth: '280px',
31
31
  maxWidth: '360px',
32
- background: `var(--vscode-menu-background, ${dark ? '#252526' : '#ffffff'})`,
33
- border: `1px solid var(--vscode-menu-border, ${dark ? '#454545' : '#d4d4d4'})`,
32
+ background: `var(--liminis-editor-menu-background, var(--vscode-menu-background, ${dark ? '#252526' : '#ffffff'}))`,
33
+ border: `1px solid var(--liminis-editor-menu-border, var(--vscode-menu-border, ${dark ? '#454545' : '#d4d4d4'}))`,
34
34
  boxShadow: dark ? '0 4px 16px rgba(0,0,0,0.4)' : '0 4px 16px rgba(0,0,0,0.15)',
35
- color: `var(--vscode-menu-foreground, ${dark ? '#cccccc' : '#333333'})`,
35
+ color: `var(--liminis-editor-menu-foreground, var(--vscode-menu-foreground, ${dark ? '#cccccc' : '#333333'}))`,
36
36
  fontFamily: 'system-ui, -apple-system, sans-serif',
37
37
  fontSize: '13px',
38
38
  };
@@ -260,8 +260,8 @@ export function CorrectionPanelPlugin() {
260
260
  if (!isOpen)
261
261
  return null;
262
262
  const dark = isDark();
263
- const borderColor = `var(--vscode-menu-border, ${dark ? '#454545' : '#d4d4d4'})`;
264
- const hoverBg = `var(--vscode-menu-selectionBackground, ${dark ? '#094771' : '#e8e8e8'})`;
263
+ const borderColor = `var(--liminis-editor-menu-border, var(--vscode-menu-border, ${dark ? '#454545' : '#d4d4d4'}))`;
264
+ const hoverBg = `var(--liminis-editor-menu-selectionBackground, var(--vscode-menu-selectionBackground, ${dark ? '#094771' : '#e8e8e8'}))`;
265
265
  const mutedColor = dark ? '#888888' : '#999999';
266
266
  const inputBg = dark ? '#1e1e1e' : '#f5f5f5';
267
267
  const warningBg = dark ? '#4a3500' : '#fff8e1';
@@ -273,12 +273,12 @@ export function CorrectionPanelPlugin() {
273
273
  cursor: 'pointer',
274
274
  border: `1px solid ${borderColor}`,
275
275
  background: 'transparent',
276
- color: `var(--vscode-menu-foreground, ${dark ? '#cccccc' : '#333333'})`,
276
+ color: `var(--liminis-editor-menu-foreground, var(--vscode-menu-foreground, ${dark ? '#cccccc' : '#333333'}))`,
277
277
  };
278
278
  const btnPrimary = {
279
279
  ...btnBase,
280
- background: `var(--vscode-button-background, ${dark ? '#0e639c' : '#007acc'})`,
281
- color: `var(--vscode-button-foreground, #ffffff)`,
280
+ background: `var(--liminis-editor-button-background, var(--vscode-button-background, ${dark ? '#0e639c' : '#007acc'}))`,
281
+ color: `var(--liminis-editor-button-foreground, var(--vscode-button-foreground, #ffffff))`,
282
282
  border: 'none',
283
283
  opacity: isSubmitting ? 0.6 : 1,
284
284
  };
@@ -297,7 +297,7 @@ export function CorrectionPanelPlugin() {
297
297
  borderRadius: '4px',
298
298
  border: `1px solid ${borderColor}`,
299
299
  background: inputBg,
300
- color: `var(--vscode-menu-foreground, ${dark ? '#cccccc' : '#333333'})`,
300
+ color: `var(--liminis-editor-menu-foreground, var(--vscode-menu-foreground, ${dark ? '#cccccc' : '#333333'}))`,
301
301
  fontSize: '13px',
302
302
  boxSizing: 'border-box',
303
303
  }, onKeyDown: (e) => {
@@ -324,7 +324,7 @@ function SuggestionItem({ name, hoverBg, onClick, }) {
324
324
  cursor: 'pointer',
325
325
  fontSize: '13px',
326
326
  borderRadius: '3px',
327
- color: `var(--vscode-menu-foreground, ${dark ? '#cccccc' : '#333333'})`,
327
+ color: `var(--liminis-editor-menu-foreground, var(--vscode-menu-foreground, ${dark ? '#cccccc' : '#333333'}))`,
328
328
  }, onMouseEnter: (e) => {
329
329
  e.currentTarget.style.background = hoverBg;
330
330
  }, onMouseLeave: (e) => {
@@ -0,0 +1,17 @@
1
+ import type { DocumentOutlineHandle } from './documentOutlineHandle.js';
2
+ export interface DocumentOutlineProps {
3
+ /** The controller created by `createDocumentOutlineHandle()` and also passed to `<Editor documentOutlineHandle={…}>`. */
4
+ handle: DocumentOutlineHandle;
5
+ /** Additional class name on the outer `<nav>`, for host placement/layout (FR-008). */
6
+ className?: string;
7
+ }
8
+ /**
9
+ * Renders a navigable "on this page" heading outline for one editor
10
+ * instance, driven by the `DocumentOutlineHandle` shared with that editor's
11
+ * `<Editor documentOutlineHandle={…}>` (issue #69).
12
+ *
13
+ * Renders nothing when the document has no headings (FR-007). Visibility,
14
+ * placement, width-gating, and collapse/persistence are the host's concern
15
+ * (FR-008) — this component only renders the list and reacts to the handle.
16
+ */
17
+ export declare function DocumentOutline({ handle, className }: DocumentOutlineProps): import("react").JSX.Element | null;
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react';
3
+ /**
4
+ * Renders a navigable "on this page" heading outline for one editor
5
+ * instance, driven by the `DocumentOutlineHandle` shared with that editor's
6
+ * `<Editor documentOutlineHandle={…}>` (issue #69).
7
+ *
8
+ * Renders nothing when the document has no headings (FR-007). Visibility,
9
+ * placement, width-gating, and collapse/persistence are the host's concern
10
+ * (FR-008) — this component only renders the list and reacts to the handle.
11
+ */
12
+ export function DocumentOutline({ handle, className }) {
13
+ // Wrapped rather than passed directly: `handle.subscribe`/`handle.getSnapshot`
14
+ // are unbound method references off a plain interface value.
15
+ const subscribe = useCallback((onStoreChange) => handle.subscribe(onStoreChange), [handle]);
16
+ const getSnapshot = useCallback(() => handle.getSnapshot(), [handle]);
17
+ const snapshot = useSyncExternalStore(subscribe, getSnapshot);
18
+ const listRef = useRef(null);
19
+ // Keep the active entry visible within the outline itself (US2-AC2).
20
+ useEffect(() => {
21
+ if (snapshot.activeIndex === null)
22
+ return;
23
+ const item = listRef.current?.querySelector(`[data-outline-index="${snapshot.activeIndex}"]`);
24
+ item?.scrollIntoView({ block: 'nearest' });
25
+ }, [snapshot.activeIndex]);
26
+ if (snapshot.entries.length === 0)
27
+ return null;
28
+ return (_jsx("nav", { className: className ? `editor-outline ${className}` : 'editor-outline', "aria-label": "Document outline", children: _jsx("ul", { className: "editor-outline-list", ref: listRef, children: snapshot.entries.map((entry) => {
29
+ const isActive = entry.index === snapshot.activeIndex;
30
+ return (_jsx("li", { "data-outline-index": entry.index, "data-outline-level": entry.level, className: isActive ? 'editor-outline-item editor-outline-item-active' : 'editor-outline-item', children: _jsxs("button", { type: "button", className: "editor-outline-item-button", "aria-current": isActive ? 'location' : undefined, onClick: () => handle.scrollToHeading(entry.index), children: [_jsx("span", { className: "editor-outline-indicator", "aria-hidden": "true" }), _jsx("span", { className: "editor-outline-item-text", children: entry.text })] }) }, entry.index));
31
+ }) }) }));
32
+ }
@@ -293,7 +293,7 @@ export function DragHandlePlugin() {
293
293
  left: 0,
294
294
  right: 0,
295
295
  height: 2,
296
- background: 'var(--vscode-focusBorder, #007acc)',
296
+ background: 'var(--liminis-editor-focusBorder, var(--vscode-focusBorder, #007acc))',
297
297
  pointerEvents: 'none',
298
298
  zIndex: 100,
299
299
  } }))] }));
@@ -1,4 +1,5 @@
1
1
  import { type MutableRefObject } from 'react';
2
+ import type { DocumentOutlineHandle } from './documentOutlineHandle.js';
2
3
  import type { SelectionContextMenuEvent } from './SelectionContextMenuPlugin.js';
3
4
  import { type SweepFn } from './AmbientCorrectionPlugin.js';
4
5
  import type { Annotation, AnnotationEditorHandle, AnnotationKindConfigs } from '../../annotations/types.js';
@@ -79,6 +80,14 @@ interface EditorProps {
79
80
  annotationLogger?: {
80
81
  warn: (message: string, ...args: unknown[]) => void;
81
82
  };
83
+ /**
84
+ * Connects this editor to a `<DocumentOutline handle={…}>` rendered
85
+ * anywhere the host chooses (issue #69). Create with
86
+ * `createDocumentOutlineHandle()` and pass the same object to both — an
87
+ * unsupplied handle mounts no outline plugin at all, so an outline-less
88
+ * consumer pays nothing for it.
89
+ */
90
+ documentOutlineHandle?: DocumentOutlineHandle;
82
91
  }
83
- export declare function Editor({ initialContent, autoFocus, contentVersion, cursorToRestoreRef, onChange, onCursorChange, onSelectionContextMenu, assetBaseUri, documentDirUri, imagePathResolution, wikiLinkPromotion, resolveLocalAsset, editable, filePath, onSubstitutionDetected, sweepRef, annotationKinds, annotations, activeAnnotationId, scrollToAnnotation, onCreateAnnotation, onActivateAnnotation, annotationEditorHandleRef, annotationLogger, }: EditorProps): import("react").JSX.Element;
92
+ export declare function Editor({ initialContent, autoFocus, contentVersion, cursorToRestoreRef, onChange, onCursorChange, onSelectionContextMenu, assetBaseUri, documentDirUri, imagePathResolution, wikiLinkPromotion, resolveLocalAsset, editable, filePath, onSubstitutionDetected, sweepRef, annotationKinds, annotations, activeAnnotationId, scrollToAnnotation, onCreateAnnotation, onActivateAnnotation, annotationEditorHandleRef, annotationLogger, documentOutlineHandle, }: EditorProps): import("react").JSX.Element;
84
93
  export {};
@@ -32,6 +32,7 @@ import { FrontmatterPlugin } from './FrontmatterPlugin.js';
32
32
  import { WikiLinkExistencePlugin } from './WikiLinkExistencePlugin.js';
33
33
  import { WikiLinkFormatPlugin } from './WikiLinkFormatPlugin.js';
34
34
  import { AnchorScrollPlugin } from './AnchorScrollPlugin.js';
35
+ import { OutlinePlugin } from './OutlinePlugin.js';
35
36
  import { SelectionContextMenuPlugin } from './SelectionContextMenuPlugin.js';
36
37
  import { CorrectionPanelPlugin } from './CorrectionPanelPlugin.js';
37
38
  import { AmbientCorrectionPlugin } from './AmbientCorrectionPlugin.js';
@@ -401,7 +402,7 @@ const DEBOUNCE_DELAY = 100;
401
402
  // Window after an external content load (file open/reload) or editor-initiated change
402
403
  // during which we suppress onChange/reload to avoid feedback loops and normalization noise.
403
404
  const POST_LOAD_SUPPRESS_MS = 500;
404
- export function Editor({ initialContent, autoFocus = false, contentVersion = 0, cursorToRestoreRef, onChange, onCursorChange, onSelectionContextMenu, assetBaseUri, documentDirUri, imagePathResolution, wikiLinkPromotion, resolveLocalAsset, editable = true, filePath, onSubstitutionDetected, sweepRef, annotationKinds, annotations, activeAnnotationId = null, scrollToAnnotation, onCreateAnnotation, onActivateAnnotation, annotationEditorHandleRef, annotationLogger, }) {
405
+ export function Editor({ initialContent, autoFocus = false, contentVersion = 0, cursorToRestoreRef, onChange, onCursorChange, onSelectionContextMenu, assetBaseUri, documentDirUri, imagePathResolution, wikiLinkPromotion, resolveLocalAsset, editable = true, filePath, onSubstitutionDetected, sweepRef, annotationKinds, annotations, activeAnnotationId = null, scrollToAnnotation, onCreateAnnotation, onActivateAnnotation, annotationEditorHandleRef, annotationLogger, documentOutlineHandle, }) {
405
406
  // Annotations are on only when the host configured at least one kind
406
407
  // (FR-004). Everything annotation-specific hangs off this flag, including
407
408
  // the dynamic import below — with no kinds, none of those modules is ever
@@ -530,7 +531,7 @@ export function Editor({ initialContent, autoFocus = false, contentVersion = 0,
530
531
  onError: editorOnError,
531
532
  editable,
532
533
  };
533
- return (_jsx(AssetContext.Provider, { value: assetContextValue, children: _jsx(LexicalComposer, { initialConfig: initialConfig, children: _jsx("div", { className: "relative max-w-[800px] ml-5 mr-1 pl-6 pr-6", children: _jsxs("div", { className: "editor-inner relative", children: [_jsx(RichTextPlugin, { contentEditable: _jsx(ContentEditable, { className: "editor-input", "aria-label": "Markdown editor" }), placeholder: _jsx("div", { className: "editor-placeholder", children: "Type '/' for commands..." }), ErrorBoundary: LexicalErrorBoundary }), _jsx(HistoryPlugin, {}), _jsx(ListPlugin, {}), _jsx(CheckListPlugin, {}), _jsx(OrderedTaskListPlugin, {}), _jsx(TabIndentationPlugin, {}), _jsx(LinkPlugin, {}), _jsx(TablePlugin, {}), _jsx(CodeHighlightPlugin, {}), _jsx(OnChangePlugin, { onChange: handleChange, ignoreSelectionChange: true }), _jsx(InitializePlugin, { content: initialContent, onOffsets: handleOffsets }), _jsx(EditablePlugin, { editable: editable }), autoFocus && _jsx(AutoFocusPlugin, {}), _jsx(CursorTrackingPlugin, { onCursorChange: onCursorChange }), _jsx(CursorRestorePlugin, { contentVersion: contentVersion, cursorToRestoreRef: effectiveCursorRef }), _jsx(ExternalUpdatePlugin, { content: initialContent, lastExternalLoadRef: lastExternalLoadRef, currentContentRef: currentContentRef, lastEditorChangeRef: lastEditorChangeRef, onOffsets: handleOffsets }), _jsx(SlashMenuPlugin, {}), _jsx(DragHandlePlugin, {}), _jsx(MarkdownShortcutsPlugin, {}), _jsx(TableActionsPlugin, {}), _jsx(CodeBlockPlugin, {}), _jsx(TogglePlugin, {}), _jsx(ImagePlugin, {}), _jsx(BlockClickPlugin, {}), _jsx(LinkClickPlugin, { editable: editable }), _jsx(Toolbar, { annotationAffordances: toolbarAnnotationAffordances }), _jsx(SearchPlugin, {}), _jsx(FrontmatterPlugin, { filePath: filePath }), _jsx(WikiLinkExistencePlugin, {}), _jsx(WikiLinkFormatPlugin, {}), _jsx(AnchorScrollPlugin, {}), _jsx(SelectionContextMenuPlugin, { onSelectionContextMenu: onSelectionContextMenu,
534
+ return (_jsx(AssetContext.Provider, { value: assetContextValue, children: _jsx(LexicalComposer, { initialConfig: initialConfig, children: _jsx("div", { className: "editor-container", children: _jsxs("div", { className: "editor-inner", children: [_jsx(RichTextPlugin, { contentEditable: _jsx(ContentEditable, { className: "editor-input", "aria-label": "Markdown editor" }), placeholder: _jsx("div", { className: "editor-placeholder", children: "Type '/' for commands..." }), ErrorBoundary: LexicalErrorBoundary }), _jsx(HistoryPlugin, {}), _jsx(ListPlugin, {}), _jsx(CheckListPlugin, {}), _jsx(OrderedTaskListPlugin, {}), _jsx(TabIndentationPlugin, {}), _jsx(LinkPlugin, {}), _jsx(TablePlugin, {}), _jsx(CodeHighlightPlugin, {}), _jsx(OnChangePlugin, { onChange: handleChange, ignoreSelectionChange: true }), _jsx(InitializePlugin, { content: initialContent, onOffsets: handleOffsets }), _jsx(EditablePlugin, { editable: editable }), autoFocus && _jsx(AutoFocusPlugin, {}), _jsx(CursorTrackingPlugin, { onCursorChange: onCursorChange }), _jsx(CursorRestorePlugin, { contentVersion: contentVersion, cursorToRestoreRef: effectiveCursorRef }), _jsx(ExternalUpdatePlugin, { content: initialContent, lastExternalLoadRef: lastExternalLoadRef, currentContentRef: currentContentRef, lastEditorChangeRef: lastEditorChangeRef, onOffsets: handleOffsets }), _jsx(SlashMenuPlugin, {}), _jsx(DragHandlePlugin, {}), _jsx(MarkdownShortcutsPlugin, {}), _jsx(TableActionsPlugin, {}), _jsx(CodeBlockPlugin, {}), _jsx(TogglePlugin, {}), _jsx(ImagePlugin, {}), _jsx(BlockClickPlugin, {}), _jsx(LinkClickPlugin, { editable: editable }), _jsx(Toolbar, { annotationAffordances: toolbarAnnotationAffordances }), _jsx(SearchPlugin, {}), _jsx(FrontmatterPlugin, { filePath: filePath }), _jsx(WikiLinkExistencePlugin, {}), _jsx(WikiLinkFormatPlugin, {}), _jsx(AnchorScrollPlugin, {}), documentOutlineHandle && _jsx(OutlinePlugin, { handle: documentOutlineHandle }), _jsx(SelectionContextMenuPlugin, { onSelectionContextMenu: onSelectionContextMenu,
534
535
  // Only kinds that actually ask for a context-menu affordance:
535
536
  // with none configured, or one on another surface, AnnotationPlugin
536
537
  // would decline the command anyway, and dispatching from here would
@@ -0,0 +1,14 @@
1
+ import { type DocumentOutlineHandle } from './documentOutlineHandle.js';
2
+ /**
3
+ * OutlinePlugin - Feeds a `DocumentOutlineHandle` from inside the Lexical
4
+ * tree: the current H1-H5 heading list (FR-002/FR-005), which heading is at
5
+ * the top of the viewport (FR-004, rAF-throttled for rapid-scroll/large-doc
6
+ * performance), and the imperative scroll-to-heading action (FR-003) the
7
+ * handle's `scrollToHeading` forwards to.
8
+ *
9
+ * Mounted only when a host supplies `documentOutlineHandle` to `<Editor>` —
10
+ * an outline-less consumer pays nothing for this plugin.
11
+ */
12
+ export declare function OutlinePlugin({ handle }: {
13
+ handle: DocumentOutlineHandle;
14
+ }): null;
@@ -0,0 +1,133 @@
1
+ import { useEffect } from 'react';
2
+ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
3
+ import { $getRoot } from 'lexical';
4
+ import { $isHeadingNode } from '@lexical/rich-text';
5
+ import { scrollContainerFor, scrollElementIntoView } from './scrollContainer.js';
6
+ const HEADING_SELECTOR = [
7
+ '.editor-heading-h1',
8
+ '.editor-heading-h2',
9
+ '.editor-heading-h3',
10
+ '.editor-heading-h4',
11
+ '.editor-heading-h5',
12
+ ].join(',');
13
+ function readHeadingEntries() {
14
+ const entries = [];
15
+ let index = 0;
16
+ for (const child of $getRoot().getChildren()) {
17
+ if (!$isHeadingNode(child))
18
+ continue;
19
+ // H6 has no theme class (`Editor.tsx`'s `editorTheme.heading` maps only
20
+ // h1-h5), so it never appears in `HEADING_SELECTOR` either — excluding it
21
+ // here keeps `entries` and the rendered heading DOM in the same order.
22
+ const level = Number(child.getTag().slice(1));
23
+ if (level < 1 || level > 5)
24
+ continue;
25
+ entries.push({ index: index++, level: level, text: child.getTextContent() });
26
+ }
27
+ return entries;
28
+ }
29
+ /**
30
+ * OutlinePlugin - Feeds a `DocumentOutlineHandle` from inside the Lexical
31
+ * tree: the current H1-H5 heading list (FR-002/FR-005), which heading is at
32
+ * the top of the viewport (FR-004, rAF-throttled for rapid-scroll/large-doc
33
+ * performance), and the imperative scroll-to-heading action (FR-003) the
34
+ * handle's `scrollToHeading` forwards to.
35
+ *
36
+ * Mounted only when a host supplies `documentOutlineHandle` to `<Editor>` —
37
+ * an outline-less consumer pays nothing for this plugin.
38
+ */
39
+ export function OutlinePlugin({ handle }) {
40
+ const [editor] = useLexicalComposerContext();
41
+ // Safe: `DocumentOutlineHandle`'s private brand (see documentOutlineHandle.ts)
42
+ // makes it nominal, so only an `OutlineHandleImpl` from
43
+ // `createDocumentOutlineHandle()` can type-check as one here.
44
+ const impl = handle;
45
+ useEffect(() => {
46
+ let entries = [];
47
+ let activeIndex = null;
48
+ // No listener is attached until `reattachScrollListener` resolves a
49
+ // first target — `null`, not `window`, so that a document whose scroll
50
+ // container falls back to `window` still gets a listener attached (a
51
+ // pre-resolved `window` initial value would equal that same fallback and
52
+ // the early-return below would skip attaching one at all).
53
+ let scrollTarget = null;
54
+ const publish = () => {
55
+ impl.publish({ entries, activeIndex });
56
+ };
57
+ let scheduledFrame = 0;
58
+ const updateActive = () => {
59
+ scheduledFrame = 0;
60
+ const rootElement = editor.getRootElement();
61
+ if (!rootElement)
62
+ return;
63
+ const headings = Array.from(rootElement.querySelectorAll(HEADING_SELECTOR));
64
+ let next = null;
65
+ if (headings.length > 0) {
66
+ const container = scrollContainerFor(headings[0]);
67
+ const referenceTop = container ? container.getBoundingClientRect().top : 0;
68
+ next = 0;
69
+ for (let i = 0; i < headings.length; i++) {
70
+ if (headings[i].getBoundingClientRect().top - referenceTop <= 0)
71
+ next = i;
72
+ else
73
+ break;
74
+ }
75
+ // Guards against a heading-DOM/entries mismatch rather than assuming
76
+ // they always agree in length.
77
+ if (next >= entries.length)
78
+ next = entries.length > 0 ? entries.length - 1 : null;
79
+ }
80
+ if (next !== activeIndex) {
81
+ activeIndex = next;
82
+ publish();
83
+ }
84
+ };
85
+ const scheduleUpdateActive = () => {
86
+ if (scheduledFrame)
87
+ return;
88
+ scheduledFrame = requestAnimationFrame(updateActive);
89
+ };
90
+ /**
91
+ * Re-resolve the scroll container from the current DOM and move the
92
+ * listener there if it changed. Called on every entries update — not
93
+ * just once at mount — because the container is normally only
94
+ * discoverable once at least one heading has actually rendered.
95
+ */
96
+ const reattachScrollListener = () => {
97
+ const rootElement = editor.getRootElement();
98
+ const firstHeading = rootElement?.querySelector(HEADING_SELECTOR) ?? null;
99
+ const nextTarget = firstHeading ? scrollContainerFor(firstHeading) ?? window : window;
100
+ if (nextTarget === scrollTarget)
101
+ return;
102
+ scrollTarget?.removeEventListener('scroll', scheduleUpdateActive);
103
+ nextTarget.addEventListener('scroll', scheduleUpdateActive, { passive: true });
104
+ scrollTarget = nextTarget;
105
+ };
106
+ const updateEntries = () => {
107
+ editor.getEditorState().read(() => {
108
+ entries = readHeadingEntries();
109
+ });
110
+ if (activeIndex !== null && activeIndex >= entries.length) {
111
+ activeIndex = entries.length > 0 ? entries.length - 1 : null;
112
+ }
113
+ reattachScrollListener();
114
+ scheduleUpdateActive();
115
+ publish();
116
+ };
117
+ updateEntries();
118
+ const unregisterUpdateListener = editor.registerUpdateListener(updateEntries);
119
+ impl.connect((index) => {
120
+ const target = editor.getRootElement()?.querySelectorAll(HEADING_SELECTOR)[index];
121
+ if (target)
122
+ scrollElementIntoView(target);
123
+ });
124
+ return () => {
125
+ unregisterUpdateListener();
126
+ scrollTarget?.removeEventListener('scroll', scheduleUpdateActive);
127
+ if (scheduledFrame)
128
+ cancelAnimationFrame(scheduledFrame);
129
+ impl.disconnect();
130
+ };
131
+ }, [editor, impl]);
132
+ return null;
133
+ }