@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.
- package/README.md +126 -14
- package/dist/annotations/types.d.ts +16 -0
- package/dist/app/App.d.ts +1 -1
- package/dist/app/App.js +3 -3
- package/dist/app/editor/AnchorScrollPlugin.js +2 -34
- package/dist/app/editor/AnnotationSurface.js +2 -1
- package/dist/app/editor/CorrectionPanelPlugin.js +10 -10
- package/dist/app/editor/DocumentOutline.d.ts +17 -0
- package/dist/app/editor/DocumentOutline.js +32 -0
- package/dist/app/editor/DragHandlePlugin.js +1 -1
- package/dist/app/editor/Editor.d.ts +10 -1
- package/dist/app/editor/Editor.js +3 -2
- package/dist/app/editor/OutlinePlugin.d.ts +14 -0
- package/dist/app/editor/OutlinePlugin.js +133 -0
- package/dist/app/editor/SelectionContextMenuPlugin.js +4 -4
- package/dist/app/editor/annotation-marks.d.ts +20 -0
- package/dist/app/editor/annotation-marks.js +40 -0
- package/dist/app/editor/documentOutlineHandle.d.ts +87 -0
- package/dist/app/editor/documentOutlineHandle.js +86 -0
- package/dist/app/editor/nodes/C4Component.js +6 -6
- package/dist/app/editor/nodes/DiagramContextMenu.js +5 -5
- package/dist/app/editor/scrollContainer.d.ts +18 -0
- package/dist/app/editor/scrollContainer.js +47 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +6 -0
- package/dist/markdown/stringify.js +65 -0
- package/dist/styles.css +342 -221
- package/docs/decisions/adr-078.md +37 -2
- package/docs/decisions/adr-085.md +166 -0
- package/docs/decisions/adr-086.md +147 -0
- package/docs/decisions/adr-087.md +216 -0
- package/docs/decisions/adr-088.md +136 -0
- package/docs/decisions/adr-089.md +121 -0
- package/docs/editor-api.md +53 -1
- 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
|
-
###
|
|
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
|
-
|
|
200
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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: "
|
|
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 = '
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
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: "
|
|
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
|
+
}
|