@dxos/react-ui-editor 0.8.4-main.406dc2a → 0.8.4-main.548089c

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 (108) hide show
  1. package/dist/lib/browser/index.mjs +1379 -1139
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node-esm/index.mjs +1379 -1139
  5. package/dist/lib/node-esm/index.mjs.map +4 -4
  6. package/dist/lib/node-esm/meta.json +1 -1
  7. package/dist/types/src/components/Editor/Editor.stories.d.ts +0 -3
  8. package/dist/types/src/components/Editor/Editor.stories.d.ts.map +1 -1
  9. package/dist/types/src/components/EditorToolbar/EditorToolbar.d.ts +17 -2
  10. package/dist/types/src/components/EditorToolbar/EditorToolbar.d.ts.map +1 -1
  11. package/dist/types/src/components/EditorToolbar/headings.d.ts.map +1 -1
  12. package/dist/types/src/components/EditorToolbar/util.d.ts +5 -19
  13. package/dist/types/src/components/EditorToolbar/util.d.ts.map +1 -1
  14. package/dist/types/src/extensions/automerge/automerge.d.ts +1 -1
  15. package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
  16. package/dist/types/src/extensions/automerge/cursor.d.ts +1 -1
  17. package/dist/types/src/extensions/automerge/cursor.d.ts.map +1 -1
  18. package/dist/types/src/extensions/automerge/sync.d.ts +1 -1
  19. package/dist/types/src/extensions/automerge/sync.d.ts.map +1 -1
  20. package/dist/types/src/extensions/automerge/update-automerge.d.ts +1 -1
  21. package/dist/types/src/extensions/automerge/update-automerge.d.ts.map +1 -1
  22. package/dist/types/src/extensions/autoscroll.d.ts +14 -4
  23. package/dist/types/src/extensions/autoscroll.d.ts.map +1 -1
  24. package/dist/types/src/extensions/awareness/awareness-provider.d.ts +1 -1
  25. package/dist/types/src/extensions/awareness/awareness-provider.d.ts.map +1 -1
  26. package/dist/types/src/extensions/blocks.d.ts +2 -0
  27. package/dist/types/src/extensions/blocks.d.ts.map +1 -0
  28. package/dist/types/src/extensions/bookmarks.d.ts +12 -0
  29. package/dist/types/src/extensions/bookmarks.d.ts.map +1 -0
  30. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  31. package/dist/types/src/extensions/factories.d.ts +4 -4
  32. package/dist/types/src/extensions/factories.d.ts.map +1 -1
  33. package/dist/types/src/extensions/folding.d.ts.map +1 -1
  34. package/dist/types/src/extensions/index.d.ts +4 -0
  35. package/dist/types/src/extensions/index.d.ts.map +1 -1
  36. package/dist/types/src/extensions/listener.d.ts +8 -6
  37. package/dist/types/src/extensions/listener.d.ts.map +1 -1
  38. package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
  39. package/dist/types/src/extensions/markdown/formatting.d.ts +1 -2
  40. package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -1
  41. package/dist/types/src/extensions/popover/PopoverMenuProvider.d.ts +1 -1
  42. package/dist/types/src/extensions/popover/PopoverMenuProvider.d.ts.map +1 -1
  43. package/dist/types/src/extensions/popover/popover.d.ts.map +1 -1
  44. package/dist/types/src/extensions/preview/preview.d.ts +6 -2
  45. package/dist/types/src/extensions/preview/preview.d.ts.map +1 -1
  46. package/dist/types/src/extensions/replacer.d.ts +21 -0
  47. package/dist/types/src/extensions/replacer.d.ts.map +1 -0
  48. package/dist/types/src/extensions/replacer.test.d.ts +2 -0
  49. package/dist/types/src/extensions/replacer.test.d.ts.map +1 -0
  50. package/dist/types/src/extensions/scrolling.d.ts +78 -0
  51. package/dist/types/src/extensions/scrolling.d.ts.map +1 -0
  52. package/dist/types/src/extensions/tags/xml-tags.d.ts +41 -16
  53. package/dist/types/src/extensions/tags/xml-tags.d.ts.map +1 -1
  54. package/dist/types/src/stories/CommandDialog.stories.d.ts.map +1 -1
  55. package/dist/types/src/stories/EditorToolbar.stories.d.ts.map +1 -1
  56. package/dist/types/src/stories/Popover.stories.d.ts.map +1 -1
  57. package/dist/types/src/stories/Preview.stories.d.ts.map +1 -1
  58. package/dist/types/src/stories/Tags.stories.d.ts.map +1 -1
  59. package/dist/types/src/stories/components/EditorStory.d.ts.map +1 -1
  60. package/dist/types/src/stories/components/util.d.ts.map +1 -1
  61. package/dist/types/tsconfig.tsbuildinfo +1 -1
  62. package/package.json +41 -38
  63. package/src/components/Editor/Editor.stories.tsx +4 -7
  64. package/src/components/EditorToolbar/EditorToolbar.tsx +90 -90
  65. package/src/components/EditorToolbar/headings.ts +6 -4
  66. package/src/components/EditorToolbar/util.ts +4 -20
  67. package/src/extensions/autocomplete/autocomplete.ts +5 -5
  68. package/src/extensions/automerge/automerge.stories.tsx +1 -1
  69. package/src/extensions/automerge/automerge.ts +1 -1
  70. package/src/extensions/automerge/cursor.ts +1 -1
  71. package/src/extensions/automerge/sync.ts +1 -1
  72. package/src/extensions/automerge/update-automerge.ts +1 -1
  73. package/src/extensions/autoscroll.ts +74 -68
  74. package/src/extensions/awareness/awareness-provider.ts +2 -2
  75. package/src/extensions/blocks.ts +131 -0
  76. package/src/extensions/bookmarks.ts +75 -0
  77. package/src/extensions/comments.ts +2 -1
  78. package/src/extensions/factories.ts +6 -4
  79. package/src/extensions/folding.tsx +1 -2
  80. package/src/extensions/index.ts +4 -0
  81. package/src/extensions/listener.ts +14 -20
  82. package/src/extensions/markdown/bundle.ts +12 -2
  83. package/src/extensions/markdown/decorate.ts +8 -8
  84. package/src/extensions/markdown/formatting.ts +8 -8
  85. package/src/extensions/markdown/highlight.ts +1 -1
  86. package/src/extensions/markdown/image.ts +2 -2
  87. package/src/extensions/markdown/table.ts +6 -6
  88. package/src/extensions/popover/PopoverMenuProvider.tsx +2 -3
  89. package/src/extensions/popover/popover.ts +0 -4
  90. package/src/extensions/preview/preview.ts +14 -9
  91. package/src/extensions/replacer.test.ts +75 -0
  92. package/src/extensions/replacer.ts +93 -0
  93. package/src/extensions/scrolling.ts +189 -0
  94. package/src/extensions/selection.ts +1 -1
  95. package/src/extensions/tags/extended-markdown.test.ts +2 -1
  96. package/src/extensions/tags/xml-tags.ts +310 -203
  97. package/src/extensions/typewriter.ts +1 -1
  98. package/src/stories/CommandDialog.stories.tsx +9 -4
  99. package/src/stories/Comments.stories.tsx +1 -1
  100. package/src/stories/EditorToolbar.stories.tsx +4 -5
  101. package/src/stories/Popover.stories.tsx +4 -6
  102. package/src/stories/Preview.stories.tsx +15 -8
  103. package/src/stories/Tags.stories.tsx +19 -5
  104. package/src/stories/TextEditor.stories.tsx +2 -2
  105. package/src/stories/components/EditorStory.tsx +3 -3
  106. package/src/stories/components/util.tsx +39 -6
  107. package/src/styles/markdown.ts +1 -1
  108. package/src/styles/theme.ts +1 -1
@@ -52,12 +52,12 @@ class LinkButton extends WidgetType {
52
52
  super();
53
53
  }
54
54
 
55
- override eq(other: this): boolean {
55
+ override eq(other: this) {
56
56
  return this.url === other.url;
57
57
  }
58
58
 
59
59
  // TODO(burdon): Create icon and link directly without react?
60
- override toDOM(view: EditorView): HTMLSpanElement {
60
+ override toDOM(view: EditorView) {
61
61
  const el = document.createElement('span');
62
62
  this.render(el, { url: this.url }, view);
63
63
  return el;
@@ -69,11 +69,15 @@ class CheckboxWidget extends WidgetType {
69
69
  super();
70
70
  }
71
71
 
72
- override eq(other: this): boolean {
72
+ override eq(other: this) {
73
73
  return this._checked === other._checked;
74
74
  }
75
75
 
76
- override toDOM(view: EditorView): HTMLSpanElement {
76
+ override ignoreEvent() {
77
+ return false;
78
+ }
79
+
80
+ override toDOM(view: EditorView) {
77
81
  const input = document.createElement('input');
78
82
  input.className = 'cm-task-checkbox dx-checkbox';
79
83
  input.type = 'checkbox';
@@ -105,10 +109,6 @@ class CheckboxWidget extends WidgetType {
105
109
  span.appendChild(input);
106
110
  return span;
107
111
  }
108
-
109
- override ignoreEvent(): boolean {
110
- return false;
111
- }
112
112
  }
113
113
 
114
114
  class TextWidget extends WidgetType {
@@ -15,10 +15,8 @@ import {
15
15
  } from '@codemirror/state';
16
16
  import { EditorView, type ViewUpdate, keymap } from '@codemirror/view';
17
17
  import { type SyntaxNode, type SyntaxNodeRef } from '@lezer/common';
18
- import { useCallback, useMemo } from 'react';
19
18
 
20
19
  import { debounceAndThrottle } from '@dxos/async';
21
- import { type Live } from '@dxos/live-object';
22
20
 
23
21
  import { type EditorToolbarState } from '../../components';
24
22
 
@@ -1251,17 +1249,19 @@ export const getFormatting = (state: EditorState): Formatting => {
1251
1249
  /**
1252
1250
  * Hook provides an extension to compute the current formatting state.
1253
1251
  */
1254
- export const useFormattingState = (state: Live<EditorToolbarState>): Extension => {
1255
- const handleUpdate = useCallback(
1252
+ export const formattingListener = (stateProvider: () => EditorToolbarState | undefined, delay = 100): Extension => {
1253
+ return EditorView.updateListener.of(
1256
1254
  debounceAndThrottle((update: ViewUpdate) => {
1257
1255
  if (update.docChanged || update.selectionSet) {
1256
+ const state = stateProvider();
1257
+ if (!state) {
1258
+ return;
1259
+ }
1260
+
1258
1261
  Object.entries(getFormatting(update.state)).forEach(([key, active]) => {
1259
1262
  state[key as keyof Formatting] = active as any;
1260
1263
  });
1261
1264
  }
1262
- }, 100),
1263
- [state],
1265
+ }, delay),
1264
1266
  );
1265
-
1266
- return useMemo(() => EditorView.updateListener.of(handleUpdate), [handleUpdate]);
1267
1267
  };
@@ -141,7 +141,7 @@ export const markdownHighlightStyle = (_options: HighlightOptions = {}) => {
141
141
 
142
142
  // Fonts.
143
143
  {
144
- tag: [tags.monospace],
144
+ tag: [tags.monospace, tags.comment],
145
145
  class: 'font-mono',
146
146
  },
147
147
 
@@ -98,11 +98,11 @@ class ImageWidget extends WidgetType {
98
98
  super();
99
99
  }
100
100
 
101
- override eq(other: this): boolean {
101
+ override eq(other: this) {
102
102
  return this._url === other._url;
103
103
  }
104
104
 
105
- override toDOM(view: EditorView): HTMLImageElement {
105
+ override toDOM(view: EditorView) {
106
106
  const img = document.createElement('img');
107
107
  img.setAttribute('src', this._url);
108
108
  img.setAttribute('class', 'cm-image');
@@ -112,14 +112,18 @@ class TableWidget extends WidgetType {
112
112
  super();
113
113
  }
114
114
 
115
- override eq(other: this): boolean {
115
+ override eq(other: this) {
116
116
  return (
117
117
  this._table.header?.join() === other._table.header?.join() &&
118
118
  this._table.rows?.join() === other._table.rows?.join()
119
119
  );
120
120
  }
121
121
 
122
- override toDOM(view: EditorView): HTMLDivElement {
122
+ override ignoreEvent(e: Event): boolean {
123
+ return !/^mouse/.test(e.type);
124
+ }
125
+
126
+ override toDOM(_view: EditorView) {
123
127
  const div = document.createElement('div');
124
128
  const table = div.appendChild(document.createElement('table'));
125
129
 
@@ -143,8 +147,4 @@ class TableWidget extends WidgetType {
143
147
 
144
148
  return div;
145
149
  }
146
-
147
- override ignoreEvent(e: Event): boolean {
148
- return !/^mouse/.test(e.type);
149
- }
150
150
  }
@@ -22,7 +22,7 @@ import { type PopoverMenuGroup, type PopoverMenuItem } from './menu';
22
22
 
23
23
  export type PopoverMenuProviderProps = PropsWithChildren<{
24
24
  view?: EditorView | null;
25
- groups: PopoverMenuGroup[];
25
+ groups?: PopoverMenuGroup[];
26
26
  currentItem?: string;
27
27
  open?: boolean;
28
28
  defaultOpen?: boolean;
@@ -77,7 +77,6 @@ export const PopoverMenuProvider = ({
77
77
  'dx-anchor-activate' as any,
78
78
  (event: DxAnchorActivate) => {
79
79
  const { trigger, refId } = event;
80
- console.log('update', trigger, refId);
81
80
 
82
81
  // If this has a `refId`, then it’s probably a URL or DXN and out of scope for this component.
83
82
  if (!refId) {
@@ -104,7 +103,7 @@ export const PopoverMenuProvider = ({
104
103
  [viewRef, onSelect],
105
104
  );
106
105
 
107
- const menuGroups = groups.filter((group) => group.items.length > 0);
106
+ const menuGroups = groups?.filter((group) => group.items.length > 0) ?? [];
108
107
 
109
108
  return (
110
109
  <Popover.Root modal={false} open={open} onOpenChange={setOpen}>
@@ -149,7 +149,6 @@ const popoverKeymap = (options: PopoverOptions) => {
149
149
 
150
150
  // Create anchor even if zero length (append space).
151
151
  const from = line.from + idx;
152
- console.log('effect', from + 1, selection.head);
153
152
  view.dispatch({
154
153
  effects: popoverRangeEffect.of({ range: { from: from + 1, to: selection.head } }),
155
154
  changes:
@@ -223,7 +222,6 @@ const popoverAnchorDecoration = (options: PopoverOptions) => {
223
222
  // Check if we should show the widget (only if cursor is within the active command range).
224
223
  const selection = view.state.selection.main;
225
224
  const showWidget = selection.head >= range.from && selection.head <= range.to;
226
- console.log('update', showWidget, range.from, range.to + 1);
227
225
  if (showWidget) {
228
226
  builder.add(
229
227
  range.from,
@@ -246,8 +244,6 @@ const popoverAnchorDecoration = (options: PopoverOptions) => {
246
244
  const content = view.state.sliceDoc(range.from + (trigger ? trigger.length : 0), range.to);
247
245
  options.onTextChange?.({ view, pos: selection.head, text: content, trigger });
248
246
  }
249
- } else {
250
- console.log('remove');
251
247
  }
252
248
 
253
249
  this._decorations = builder.finish();
@@ -7,6 +7,11 @@ import { type EditorState, type Extension, RangeSetBuilder, StateField } from '@
7
7
  import { Decoration, type DecorationSet, EditorView, WidgetType } from '@codemirror/view';
8
8
  import { type SyntaxNode } from '@lezer/common';
9
9
 
10
+ export type PreviewBlock = {
11
+ link: PreviewLinkRef;
12
+ el: HTMLElement;
13
+ };
14
+
10
15
  export type PreviewLinkRef = {
11
16
  suggest?: boolean;
12
17
  block?: boolean;
@@ -21,8 +26,8 @@ export type PreviewLinkTarget = {
21
26
  };
22
27
 
23
28
  export type PreviewOptions = {
24
- addBlockContainer?: (link: PreviewLinkRef, el: HTMLElement) => void;
25
- removeBlockContainer?: (link: PreviewLinkRef) => void;
29
+ addBlockContainer?: (block: PreviewBlock) => void;
30
+ removeBlockContainer?: (block: PreviewBlock) => void;
26
31
  };
27
32
 
28
33
  /**
@@ -142,11 +147,11 @@ class PreviewInlineWidget extends WidgetType {
142
147
  // return false;
143
148
  // }
144
149
 
145
- override eq(other: this): boolean {
150
+ override eq(other: this) {
146
151
  return this._link.ref === other._link.ref && this._link.label === other._link.label;
147
152
  }
148
153
 
149
- override toDOM(_view: EditorView): HTMLElement {
154
+ override toDOM(_view: EditorView) {
150
155
  const root = document.createElement('dx-anchor');
151
156
  root.classList.add('dx-tag--anchor');
152
157
  root.textContent = this._link.label;
@@ -171,18 +176,18 @@ class PreviewBlockWidget extends WidgetType {
171
176
  // return true;
172
177
  // }
173
178
 
174
- override eq(other: this): boolean {
179
+ override eq(other: this) {
175
180
  return this._link.ref === other._link.ref;
176
181
  }
177
182
 
178
- override toDOM(_view: EditorView): HTMLDivElement {
183
+ override toDOM(_view: EditorView) {
179
184
  const root = document.createElement('div');
180
185
  root.classList.add('cm-preview-block', 'density-coarse');
181
- this._options.addBlockContainer?.(this._link, root);
186
+ this._options.addBlockContainer?.({ link: this._link, el: root });
182
187
  return root;
183
188
  }
184
189
 
185
- override destroy() {
186
- this._options.removeBlockContainer?.(this._link);
190
+ override destroy(root: HTMLDivElement) {
191
+ this._options.removeBlockContainer?.({ link: this._link, el: root });
187
192
  }
188
193
  }
@@ -0,0 +1,75 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { EditorState } from '@codemirror/state';
6
+ import { EditorView } from '@codemirror/view';
7
+ import { describe, expect, test } from 'vitest';
8
+
9
+ import { replacer } from './replacer';
10
+
11
+ describe('replacer extension', () => {
12
+ test('creates extension with custom replacements and simulates typing', () => {
13
+ const state = EditorState.create({
14
+ extensions: [
15
+ replacer({
16
+ replacements: [
17
+ { input: ':)', output: '😊' },
18
+ { input: ':(', output: '😢' },
19
+ ],
20
+ }),
21
+ ],
22
+ doc: '',
23
+ });
24
+
25
+ // Create a minimal mock EditorView to test input handler
26
+ let currentState = state;
27
+ const mockView = {
28
+ get state() {
29
+ return currentState;
30
+ },
31
+ dispatch: (transaction: any) => {
32
+ currentState = transaction.state || currentState.update(transaction).state;
33
+ },
34
+ } as any;
35
+
36
+ // Get the input handler from the extension
37
+ const extensions = currentState.facet(EditorView.inputHandler);
38
+ const inputHandler = extensions[0];
39
+
40
+ // Test typing ':' first - should not trigger replacement.
41
+ let handled = inputHandler(mockView, 0, 0, ':', () =>
42
+ mockView.state.update({ changes: { from: 0, to: 0, insert: ':' } }),
43
+ );
44
+ expect(handled).toBe(false); // Should not handle single ':'
45
+
46
+ // Manually insert ':' to simulate first character.
47
+ mockView.dispatch({
48
+ changes: { from: 0, to: 0, insert: ':' },
49
+ selection: { anchor: 1 },
50
+ });
51
+ expect(mockView.state.doc.toString()).toBe(':');
52
+
53
+ // Test typing ')' which should trigger replacement.
54
+ // The input handler is called with the position where the character will be inserted.
55
+ // and it should handle the replacement before the character is actually inserted.
56
+ handled = inputHandler(mockView, 1, 1, ')', () =>
57
+ mockView.state.update({ changes: { from: 1, to: 1, insert: ')' } }),
58
+ );
59
+ expect(handled).toBe(true); // Should handle and replace ':)'
60
+ expect(mockView.state.doc.toString()).toBe('😊');
61
+ });
62
+
63
+ test('creates extension with default replacements', () => {
64
+ const state = EditorState.create({
65
+ extensions: [replacer()],
66
+ doc: 'test',
67
+ });
68
+
69
+ expect(state.doc.toString()).toBe('test');
70
+
71
+ // Verify the extension is installed.
72
+ const inputHandlers = state.facet(EditorView.inputHandler);
73
+ expect(inputHandlers.length).toBeGreaterThan(0);
74
+ });
75
+ });
@@ -0,0 +1,93 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type Extension } from '@codemirror/state';
6
+ import { EditorView } from '@codemirror/view';
7
+
8
+ type Replacement = {
9
+ input: string;
10
+ output: string;
11
+ };
12
+
13
+ /**
14
+ * Default character replacements for common typography.
15
+ */
16
+ export const defaultReplacements: Replacement[] = [
17
+ { input: '--', output: '—' },
18
+ { input: '...', output: '…' },
19
+ { input: '->', output: '→' },
20
+ { input: '<-', output: '←' },
21
+ { input: '=>', output: '⇒' },
22
+ { input: '<=>', output: '⇔' },
23
+ { input: '+-', output: '±' },
24
+ { input: '!=', output: '≠' },
25
+ { input: '<=', output: '≤' },
26
+ { input: '>=', output: '≥' },
27
+ { input: '(c)', output: '©' },
28
+ { input: 'EUR', output: '€' },
29
+ { input: 'GBP', output: '£' },
30
+ { input: 'BTC', output: '₿' },
31
+ ];
32
+
33
+ /**
34
+ * Options for the replacer extension.
35
+ */
36
+ export interface ReplacerOptions {
37
+ replacements?: Replacement[];
38
+ }
39
+
40
+ /**
41
+ * Creates a CodeMirror extension that automatically replaces typed character sequences.
42
+ */
43
+ export const replacer = ({ replacements = defaultReplacements }: ReplacerOptions = {}): Extension => {
44
+ // Sort replacements by input length (longest first) to handle overlapping patterns correctly.
45
+ const sortedReplacements = [...replacements].sort((a, b) => b.input.length - a.input.length);
46
+
47
+ return EditorView.inputHandler.of((view, from, to, insert) => {
48
+ // Only process single character insertions for performance.
49
+ if (insert.length !== 1) {
50
+ return false;
51
+ }
52
+
53
+ const state = view.state;
54
+ const doc = state.doc;
55
+
56
+ // Get the text before the insertion point to check for patterns.
57
+ const lineStart = doc.lineAt(from).from;
58
+ const textBefore = doc.sliceString(lineStart, from);
59
+ const textWithInsert = textBefore + insert;
60
+
61
+ // Check each replacement pattern.
62
+ for (const replacement of sortedReplacements) {
63
+ if (textWithInsert.endsWith(replacement.input)) {
64
+ const range = {
65
+ from: from - replacement.input.length + 1,
66
+ to: from,
67
+ };
68
+
69
+ // Ensure we don't go before the line start.
70
+ if (range.from < lineStart) {
71
+ continue;
72
+ }
73
+
74
+ // Create the replacement transaction.
75
+ view.dispatch(
76
+ state.update({
77
+ changes: {
78
+ ...range,
79
+ insert: replacement.output,
80
+ },
81
+ selection: {
82
+ anchor: range.from + replacement.output.length,
83
+ },
84
+ }),
85
+ );
86
+
87
+ return true;
88
+ }
89
+ }
90
+
91
+ return false;
92
+ });
93
+ };
@@ -0,0 +1,189 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { StateEffect } from '@codemirror/state';
6
+ import { EditorView, ViewPlugin } from '@codemirror/view';
7
+
8
+ /**
9
+ * Configuration options for smooth scrolling behavior.
10
+ */
11
+ export type SmoothScrollOptions = {
12
+ /**
13
+ * Additional offset from the target line in pixels.
14
+ * Positive values scroll past the line, negative values stop before it.
15
+ * @default 0
16
+ */
17
+ offset?: number;
18
+ /**
19
+ * Position of the target line in the viewport.
20
+ * - 'start': Line appears at the start (top) of the screen
21
+ * - 'end': Line appears at the end (bottom) of the screen
22
+ * @default 'start'
23
+ */
24
+ position?: 'start' | 'end';
25
+ /**
26
+ * Whether to use smooth scrolling.
27
+ * @default 'smooth'
28
+ */
29
+ behavior?: ScrollBehavior;
30
+ };
31
+
32
+ /**
33
+ * Parameters for the scroll to line effect.
34
+ */
35
+ export type ScrollToLineParams = {
36
+ /**
37
+ * The line number to scroll to (1-based).
38
+ */
39
+ line: number;
40
+ /**
41
+ * Optional configuration to override default scroll behavior.
42
+ */
43
+ options?: SmoothScrollOptions;
44
+ };
45
+
46
+ /**
47
+ * StateEffect for triggering smooth scroll to a specific line.
48
+ */
49
+ export const scrollToLineEffect = StateEffect.define<ScrollToLineParams>();
50
+
51
+ /**
52
+ * Extension that provides smooth scrolling to specific lines in the editor.
53
+ *
54
+ * @example
55
+ * ```typescript
56
+ * // Add to editor extensions.
57
+ * const extensions = [
58
+ * smoothScroll()
59
+ * ];
60
+ *
61
+ * // Trigger scroll to line 42.
62
+ * view.dispatch({
63
+ * effects: scrollToLineEffect.of({ line: 42 })
64
+ * });
65
+ *
66
+ * // Scroll with custom options.
67
+ * view.dispatch({
68
+ * effects: scrollToLineEffect.of({ line: 100, options: { offset: -50 } })
69
+ * });
70
+ *
71
+ * // Scroll so line appears at end (bottom) of screen.
72
+ * view.dispatch({
73
+ * effects: scrollToLineEffect.of({ line: 50, options: { position: 'end' } })
74
+ * });
75
+ * ```
76
+ */
77
+ export const smoothScroll = ({ offset = 0, position = 'start' }: Partial<SmoothScrollOptions> = {}) => {
78
+ // ViewPlugin to manage scroll animations.
79
+ const scrollPlugin = ViewPlugin.fromClass(
80
+ class SmoothScrollPlugin {
81
+ constructor(private readonly view: EditorView) {}
82
+
83
+ // No-op.
84
+ destroy() {}
85
+
86
+ /**
87
+ * Perform smooth scroll to the specified line.
88
+ */
89
+ scrollToLine(lineNumber: number, options: SmoothScrollOptions) {
90
+ const { offset: animOffset = 0, position: animPosition, behavior } = options;
91
+ const doc = this.view.state.doc;
92
+ const scroller = this.view.scrollDOM;
93
+
94
+ // Convert 1-based line number to 0-based.
95
+ const targetLine = Math.max(0, lineNumber - 1);
96
+ if (behavior === 'instant') {
97
+ requestAnimationFrame(() => {
98
+ this.view.dispatch({
99
+ selection: { anchor: doc.line(targetLine + 1).from },
100
+ scrollIntoView: true,
101
+ });
102
+ });
103
+ return;
104
+ }
105
+
106
+ // Get the position of the target line.
107
+ if (targetLine >= doc.lines) {
108
+ // Line doesn't exist, scroll to end.
109
+ const targetScrollTop = scroller.scrollHeight - scroller.clientHeight + (animOffset || 0);
110
+ this.animateScroll(scroller, targetScrollTop);
111
+ return;
112
+ }
113
+
114
+ const lineStart = doc.line(targetLine + 1).from;
115
+ const coords = this.view.coordsAtPos(lineStart);
116
+ if (!coords) {
117
+ return;
118
+ }
119
+
120
+ // Calculate target scroll position based on position option.
121
+ const currentScrollTop = scroller.scrollTop;
122
+ const scrollerRect = scroller.getBoundingClientRect();
123
+ const maxScrollTop = scroller.scrollHeight - scroller.clientHeight;
124
+
125
+ let targetScrollTop: number;
126
+ if (animPosition === 'end') {
127
+ // Position line at end (bottom) of viewport.
128
+ // Calculate how far down we need to scroll so the line's bottom aligns with viewport bottom.
129
+ targetScrollTop = currentScrollTop + coords.bottom - scrollerRect.bottom + animOffset;
130
+ } else {
131
+ // Default: position line at start (top) of viewport.
132
+ targetScrollTop = currentScrollTop + coords.top - scrollerRect.top + animOffset;
133
+ }
134
+
135
+ // Clamp to valid scroll range.
136
+ const clampedScrollTop = Math.max(0, Math.min(targetScrollTop, maxScrollTop));
137
+ this.animateScroll(scroller, clampedScrollTop);
138
+ }
139
+
140
+ /**
141
+ * Animate scroll using browser's built-in smooth scrolling.
142
+ */
143
+ private animateScroll(element: HTMLElement, targetScrollTop: number) {
144
+ if (Math.abs(targetScrollTop - element.scrollTop) < 1) {
145
+ return;
146
+ }
147
+
148
+ // Use browser's built-in smooth scrolling.
149
+ element.scrollTo({
150
+ top: targetScrollTop,
151
+ behavior: 'smooth',
152
+ });
153
+ }
154
+ },
155
+ );
156
+
157
+ return [
158
+ scrollPlugin,
159
+
160
+ // Update listener to handle scroll effects.
161
+ EditorView.updateListener.of((update) => {
162
+ update.transactions.forEach((transaction) => {
163
+ for (const effect of transaction.effects) {
164
+ if (effect.is(scrollToLineEffect)) {
165
+ const { line, options = {} } = effect.value;
166
+ const plugin = update.view.plugin(scrollPlugin);
167
+ if (plugin) {
168
+ plugin.scrollToLine(line, { offset, position, ...options });
169
+ }
170
+ }
171
+ }
172
+ });
173
+ }),
174
+ ];
175
+ };
176
+
177
+ /**
178
+ * Helper function to scroll to a specific line.
179
+ * This is a convenience function that can be used directly with an EditorView.
180
+ *
181
+ * @param view - The CodeMirror EditorView instance
182
+ * @param line - The line number to scroll to (1-based)
183
+ * @param options - Optional scroll configuration
184
+ */
185
+ export const scrollToLine = (view: EditorView, line: number, options?: SmoothScrollOptions) => {
186
+ view.dispatch({
187
+ effects: scrollToLineEffect.of({ line, options }),
188
+ });
189
+ };
@@ -86,7 +86,7 @@ export const selectionState = ({ getState, setState }: Partial<EditorStateStore>
86
86
  getState &&
87
87
  keymap.of([
88
88
  {
89
- key: 'ctrl-r', // TODO(burdon): Setting to jump back to selection.
89
+ key: 'Ctrl-r', // TODO(burdon): Setting to jump back to selection.
90
90
  run: (view) => {
91
91
  const state = getState(view.state.facet(documentId));
92
92
  if (state) {
@@ -40,8 +40,9 @@ describe('extended-markdown', () => {
40
40
  <toolkit />
41
41
  `;
42
42
 
43
- const nodes: SyntaxNode[] = [];
44
43
  const state = createEditorState(doc);
44
+
45
+ const nodes: SyntaxNode[] = [];
45
46
  const tree = syntaxTree(state);
46
47
  tree.iterate({
47
48
  enter: (node) => {