@dxos/ui-editor 0.0.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 (93) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +21 -0
  3. package/package.json +121 -0
  4. package/src/defaults.ts +34 -0
  5. package/src/extensions/annotations.ts +55 -0
  6. package/src/extensions/autocomplete/autocomplete.ts +151 -0
  7. package/src/extensions/autocomplete/index.ts +8 -0
  8. package/src/extensions/autocomplete/match.ts +46 -0
  9. package/src/extensions/autocomplete/placeholder.ts +117 -0
  10. package/src/extensions/autocomplete/typeahead.ts +87 -0
  11. package/src/extensions/automerge/automerge.test.tsx +76 -0
  12. package/src/extensions/automerge/automerge.ts +105 -0
  13. package/src/extensions/automerge/cursor.ts +28 -0
  14. package/src/extensions/automerge/defs.ts +31 -0
  15. package/src/extensions/automerge/index.ts +5 -0
  16. package/src/extensions/automerge/sync.ts +79 -0
  17. package/src/extensions/automerge/update-automerge.ts +50 -0
  18. package/src/extensions/automerge/update-codemirror.ts +115 -0
  19. package/src/extensions/autoscroll.ts +165 -0
  20. package/src/extensions/awareness/awareness-provider.ts +127 -0
  21. package/src/extensions/awareness/awareness.ts +315 -0
  22. package/src/extensions/awareness/index.ts +6 -0
  23. package/src/extensions/blast.ts +363 -0
  24. package/src/extensions/blocks.ts +131 -0
  25. package/src/extensions/bookmarks.ts +77 -0
  26. package/src/extensions/comments.ts +579 -0
  27. package/src/extensions/debug.ts +15 -0
  28. package/src/extensions/dnd.ts +39 -0
  29. package/src/extensions/factories.ts +284 -0
  30. package/src/extensions/focus.ts +36 -0
  31. package/src/extensions/folding.ts +63 -0
  32. package/src/extensions/hashtag.ts +68 -0
  33. package/src/extensions/index.ts +34 -0
  34. package/src/extensions/json.ts +57 -0
  35. package/src/extensions/listener.ts +32 -0
  36. package/src/extensions/markdown/action.ts +117 -0
  37. package/src/extensions/markdown/bundle.ts +105 -0
  38. package/src/extensions/markdown/changes.test.ts +26 -0
  39. package/src/extensions/markdown/changes.ts +149 -0
  40. package/src/extensions/markdown/debug.ts +44 -0
  41. package/src/extensions/markdown/decorate.ts +622 -0
  42. package/src/extensions/markdown/formatting.test.ts +498 -0
  43. package/src/extensions/markdown/formatting.ts +1265 -0
  44. package/src/extensions/markdown/highlight.ts +183 -0
  45. package/src/extensions/markdown/image.ts +118 -0
  46. package/src/extensions/markdown/index.ts +13 -0
  47. package/src/extensions/markdown/link.ts +50 -0
  48. package/src/extensions/markdown/parser.test.ts +75 -0
  49. package/src/extensions/markdown/styles.ts +135 -0
  50. package/src/extensions/markdown/table.ts +150 -0
  51. package/src/extensions/mention.ts +41 -0
  52. package/src/extensions/modal.ts +24 -0
  53. package/src/extensions/modes.ts +41 -0
  54. package/src/extensions/outliner/commands.ts +270 -0
  55. package/src/extensions/outliner/editor.test.ts +33 -0
  56. package/src/extensions/outliner/editor.ts +184 -0
  57. package/src/extensions/outliner/index.ts +7 -0
  58. package/src/extensions/outliner/menu.ts +128 -0
  59. package/src/extensions/outliner/outliner.test.ts +100 -0
  60. package/src/extensions/outliner/outliner.ts +167 -0
  61. package/src/extensions/outliner/selection.ts +50 -0
  62. package/src/extensions/outliner/tree.test.ts +168 -0
  63. package/src/extensions/outliner/tree.ts +317 -0
  64. package/src/extensions/preview/index.ts +5 -0
  65. package/src/extensions/preview/preview.ts +193 -0
  66. package/src/extensions/replacer.test.ts +75 -0
  67. package/src/extensions/replacer.ts +93 -0
  68. package/src/extensions/scrolling.ts +189 -0
  69. package/src/extensions/selection.ts +100 -0
  70. package/src/extensions/state.ts +7 -0
  71. package/src/extensions/submit.ts +62 -0
  72. package/src/extensions/tags/extended-markdown.test.ts +263 -0
  73. package/src/extensions/tags/extended-markdown.ts +78 -0
  74. package/src/extensions/tags/index.ts +7 -0
  75. package/src/extensions/tags/streamer.ts +243 -0
  76. package/src/extensions/tags/xml-tags.ts +507 -0
  77. package/src/extensions/tags/xml-util.test.ts +48 -0
  78. package/src/extensions/tags/xml-util.ts +93 -0
  79. package/src/extensions/typewriter.ts +68 -0
  80. package/src/index.ts +14 -0
  81. package/src/styles/index.ts +7 -0
  82. package/src/styles/markdown.ts +26 -0
  83. package/src/styles/theme.ts +293 -0
  84. package/src/styles/tokens.ts +17 -0
  85. package/src/types/index.ts +5 -0
  86. package/src/types/types.ts +32 -0
  87. package/src/util/cursor.ts +56 -0
  88. package/src/util/debug.ts +56 -0
  89. package/src/util/decorations.ts +21 -0
  90. package/src/util/dom.ts +36 -0
  91. package/src/util/facet.ts +13 -0
  92. package/src/util/index.ts +10 -0
  93. package/src/util/util.ts +29 -0
@@ -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 ScrollToLineProps = {
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<ScrollToLineProps>();
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
+ };
@@ -0,0 +1,100 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { type Extension, Transaction, type TransactionSpec } from '@codemirror/state';
6
+ import { EditorView, keymap } from '@codemirror/view';
7
+
8
+ import { debounce } from '@dxos/async';
9
+ import { invariant } from '@dxos/invariant';
10
+ import { isTruthy } from '@dxos/util';
11
+
12
+ import { singleValueFacet } from '../util';
13
+
14
+ /**
15
+ * Currently edited document id as FQ string.
16
+ */
17
+ export const documentId = singleValueFacet<string>();
18
+
19
+ export type EditorSelection = {
20
+ anchor: number;
21
+ head?: number;
22
+ };
23
+
24
+ export type EditorSelectionState = {
25
+ scrollTo?: number;
26
+ selection?: EditorSelection;
27
+ };
28
+
29
+ export type EditorStateStore = {
30
+ setState: (id: string, state: EditorSelectionState) => void;
31
+ getState: (id: string) => EditorSelectionState | undefined;
32
+ };
33
+
34
+ const stateRestoreAnnotation = 'dxos.org/cm/state-restore';
35
+
36
+ export const createEditorStateTransaction = ({ scrollTo, selection }: EditorSelectionState): TransactionSpec => {
37
+ return {
38
+ selection,
39
+ scrollIntoView: !scrollTo,
40
+ effects: scrollTo ? EditorView.scrollIntoView(scrollTo, { yMargin: 96 }) : undefined,
41
+ annotations: Transaction.userEvent.of(stateRestoreAnnotation),
42
+ };
43
+ };
44
+
45
+ export const createEditorStateStore = (keyPrefix: string): EditorStateStore => ({
46
+ getState: (id) => {
47
+ invariant(id);
48
+ const state = localStorage.getItem(`${keyPrefix}/${id}`);
49
+ return state ? JSON.parse(state) : undefined;
50
+ },
51
+
52
+ setState: (id, state) => {
53
+ invariant(id);
54
+ localStorage.setItem(`${keyPrefix}/${id}`, JSON.stringify(state));
55
+ },
56
+ });
57
+
58
+ /**
59
+ * Track scrolling and selection state to be restored when switching to document.
60
+ */
61
+ export const selectionState = ({ getState, setState }: Partial<EditorStateStore> = {}): Extension => {
62
+ const setStateDebounced = debounce(setState!, 1_000);
63
+
64
+ return [
65
+ // TODO(burdon): Track scrolling (currently only updates when cursor moves).
66
+ // EditorView.domEventHandlers({
67
+ // scroll: (event) => {
68
+ // setStateDebounced(id, {});
69
+ // },
70
+ // }),
71
+ EditorView.updateListener.of(({ view, transactions }) => {
72
+ const id = view.state.facet(documentId);
73
+ if (!id || transactions.some((tr) => tr.isUserEvent(stateRestoreAnnotation))) {
74
+ return;
75
+ }
76
+
77
+ if (setState) {
78
+ const { scrollTop } = view.scrollDOM;
79
+ const pos = view.posAtCoords({ x: 0, y: scrollTop });
80
+ if (pos !== null) {
81
+ const { anchor, head } = view.state.selection.main;
82
+ setStateDebounced(id, { scrollTo: pos, selection: { anchor, head } });
83
+ }
84
+ }
85
+ }),
86
+ getState &&
87
+ keymap.of([
88
+ {
89
+ key: 'Ctrl-r', // TODO(burdon): Setting to jump back to selection.
90
+ run: (view) => {
91
+ const state = getState(view.state.facet(documentId));
92
+ if (state) {
93
+ view.dispatch(createEditorStateTransaction(state));
94
+ }
95
+ return true;
96
+ },
97
+ },
98
+ ]),
99
+ ].filter(isTruthy);
100
+ };
@@ -0,0 +1,7 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { Transaction } from '@codemirror/state';
6
+
7
+ export const initialSync = Transaction.userEvent.of('initial.sync');
@@ -0,0 +1,62 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type Extension, Prec } from '@codemirror/state';
6
+ import { keymap } from '@codemirror/view';
7
+
8
+ export type SubmitOptions = {
9
+ fireIfEmpty?: boolean;
10
+ onSubmit?: (text: string) => boolean | void;
11
+ };
12
+
13
+ /**
14
+ * Handles Enter and Shift-Enter.
15
+ */
16
+ export const submit = ({ fireIfEmpty = false, onSubmit }: SubmitOptions = {}): Extension => {
17
+ return [
18
+ Prec.highest(
19
+ keymap.of([
20
+ {
21
+ key: 'Enter',
22
+ preventDefault: true,
23
+ run: (view) => {
24
+ const text = view.state.doc.toString().trim();
25
+ if (onSubmit && (fireIfEmpty || text.length > 0)) {
26
+ const reset = onSubmit(text);
27
+ if (reset) {
28
+ // Clear the document after calling onEnter.
29
+ view.dispatch({
30
+ changes: {
31
+ from: 0,
32
+ to: view.state.doc.length,
33
+ insert: '',
34
+ },
35
+ });
36
+ }
37
+ }
38
+
39
+ return true;
40
+ },
41
+ },
42
+ {
43
+ key: 'Shift-Enter',
44
+ preventDefault: true,
45
+ run: (view) => {
46
+ view.dispatch({
47
+ changes: {
48
+ from: view.state.selection.main.head,
49
+ insert: '\n',
50
+ },
51
+ selection: {
52
+ anchor: view.state.selection.main.head + 1,
53
+ head: view.state.selection.main.head + 1,
54
+ },
55
+ });
56
+ return true;
57
+ },
58
+ },
59
+ ]),
60
+ ),
61
+ ];
62
+ };