@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
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ //
2
+ // Copyright 2022 DXOS.org
3
+ //
4
+
5
+ export { type Extension, EditorState } from '@codemirror/state';
6
+ export { EditorView, keymap } from '@codemirror/view';
7
+ export { tags } from '@lezer/highlight';
8
+
9
+ export { TextKind } from '@dxos/protocols/proto/dxos/echo/model/text';
10
+
11
+ export * from './defaults';
12
+ export * from './extensions';
13
+ export * from './types';
14
+ export * from './util';
@@ -0,0 +1,7 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ export * from './markdown';
6
+ export * from './theme';
7
+ export * from './tokens';
@@ -0,0 +1,26 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { mx } from '@dxos/ui-theme';
6
+
7
+ export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
8
+
9
+ // https://tailwindcss.com/docs/font-weight
10
+ const headings: Record<HeadingLevel, string> = {
11
+ 1: 'text-4xl',
12
+ 2: 'text-3xl',
13
+ 3: 'text-2xl',
14
+ 4: 'text-xl',
15
+ 5: 'text-lg',
16
+ 6: '', // TODO(burdon): Should be text-base, but that's a color in our system.
17
+ };
18
+
19
+ export const markdownTheme = {
20
+ code: 'font-mono !no-underline text-neutral-700 dark:text-neutral-300',
21
+ codeMark: 'font-mono text-primary-500',
22
+ mark: 'opacity-50',
23
+ heading: (level: HeadingLevel) => {
24
+ return mx(headings[level], 'dark:text-neutral-400');
25
+ },
26
+ };
@@ -0,0 +1,293 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { type Extension } from '@codemirror/state';
6
+ import { EditorView } from '@codemirror/view';
7
+
8
+ import { fontBody, fontMono } from './tokens';
9
+
10
+ /**
11
+ * Global base theme.
12
+ *
13
+ * NOTE: The base theme is GLOBAL and is applied to ALL editors.
14
+ * NOTE: `light` and `dark` selectors are preprocessed by CodeMirror and can only be in the base theme.
15
+ * NOTE: Use 'unset' to remove default CM style.
16
+ *
17
+ * Examples:
18
+ * - https://codemirror.net/examples/styling
19
+ * - https://github.com/codemirror/view/blob/main/src/theme.ts
20
+ * - https://github.com/codemirror/theme-one-dark/blob/main/src/one-dark.ts
21
+ *
22
+ * Main layout:
23
+ * - https://codemirror.net/examples/styling
24
+ * - https://codemirror.net/docs/guide (DOM Structure).
25
+ *
26
+ * <div class="cm-editor [cm-focused] [generated classes]">
27
+ * <div class="cm-scroller">
28
+ * <div class="cm-gutters">
29
+ * <div class="cm-gutter [...]">
30
+ * <div class="cm-gutterElement">...</div>
31
+ * </div>
32
+ * </div>
33
+ * <div class="cm-content" role="textbox" contenteditable="true">
34
+ * <div class="cm-line"></div>
35
+ * </div>
36
+ * <div class="cm-selectionLayer">
37
+ * <div class="cm-selectionBackground"></div>
38
+ * </div>
39
+ * <div class="cm-cursorLayer">
40
+ * <div class="cm-cursor"></div>
41
+ * </div>
42
+ * </div>
43
+ * </div>
44
+ */
45
+ export const baseTheme = EditorView.baseTheme({
46
+ '&': {},
47
+ '&.cm-focused': {
48
+ outline: 'none',
49
+ },
50
+
51
+ /**
52
+ * Scroller
53
+ */
54
+ '.cm-scroller': {
55
+ overflowY: 'auto',
56
+ },
57
+
58
+ /**
59
+ * Content
60
+ * NOTE: Apply margins to content so that scrollbar is at the edge of the container.
61
+ */
62
+ '.cm-content': {
63
+ padding: 'unset',
64
+ lineHeight: '24px',
65
+ color: 'unset',
66
+ },
67
+
68
+ /**
69
+ * Gutters
70
+ * NOTE: Gutters should have the same top margin as the content.
71
+ */
72
+ '.cm-gutters': {
73
+ background: 'transparent',
74
+ borderRight: 'none',
75
+ },
76
+ '.cm-gutter': {},
77
+ '.cm-gutter.cm-lineNumbers': {
78
+ paddingRight: '4px',
79
+ borderRight: '1px solid var(--dx-subduedSeparator)',
80
+ color: 'var(--dx-subduedText)',
81
+ },
82
+ '.cm-gutter.cm-lineNumbers .cm-gutterElement': {
83
+ minWidth: '40px',
84
+ },
85
+ /**
86
+ * Height is set to match the corresponding line (which may have wrapped).
87
+ */
88
+ '.cm-gutterElement': {
89
+ lineHeight: '24px',
90
+ fontSize: '12px',
91
+ },
92
+
93
+ /**
94
+ * Line.
95
+ */
96
+ '.cm-line': {
97
+ lineHeight: '24px',
98
+ paddingInline: 0,
99
+ },
100
+ '.cm-activeLine': {
101
+ background: 'var(--dx-cmActiveLine)',
102
+ },
103
+
104
+ /**
105
+ * Cursor (layer).
106
+ */
107
+ '.cm-cursor, .cm-dropCursor': {
108
+ borderLeft: '2px solid var(--dx-cmCursor)',
109
+ },
110
+ '.cm-placeholder': {
111
+ color: 'var(--dx-placeholder)',
112
+ },
113
+
114
+ /**
115
+ * Selection (layer).
116
+ */
117
+ '.cm-selectionBackground': {
118
+ background: 'var(--dx-cmSelection)',
119
+ },
120
+ '&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
121
+ background: 'var(--dx-cmFocusedSelection)',
122
+ },
123
+
124
+ /**
125
+ * Search.
126
+ * NOTE: Matches comment.
127
+ */
128
+ '.cm-searchMatch': {
129
+ margin: '0 -3px',
130
+ padding: '3px',
131
+ borderRadius: '3px',
132
+ background: 'var(--dx-cmHighlightSurface)',
133
+ color: 'var(--dx-cmHighlight)',
134
+ },
135
+ '.cm-searchMatch-selected': {
136
+ textDecoration: 'underline',
137
+ },
138
+
139
+ /**
140
+ * Link.
141
+ */
142
+ '.cm-link': {
143
+ textDecorationLine: 'underline',
144
+ textDecorationThickness: '1px',
145
+ textDecorationColor: 'var(--dx-separator)',
146
+ textUnderlineOffset: '2px',
147
+ borderRadius: '.125rem',
148
+ },
149
+ '.cm-link > span': {
150
+ color: 'var(--dx-accentText)',
151
+ },
152
+
153
+ /**
154
+ * Tooltip.
155
+ */
156
+ '.cm-tooltip': {
157
+ background: 'var(--dx-baseSurface)',
158
+ },
159
+ '.cm-tooltip-below': {},
160
+
161
+ /**
162
+ * Autocomplete.
163
+ * https://github.com/codemirror/autocomplete/blob/main/src/completion.ts
164
+ */
165
+ '.cm-tooltip.cm-tooltip-autocomplete': {
166
+ marginTop: '6px',
167
+ marginLeft: '-10px',
168
+ border: '2px solid var(--dx-separator)',
169
+ borderRadius: '4px',
170
+ },
171
+ '.cm-tooltip.cm-tooltip-autocomplete > ul': {
172
+ maxHeight: '20em',
173
+ },
174
+ '.cm-tooltip.cm-tooltip-autocomplete > ul > li': {
175
+ padding: '4px',
176
+ },
177
+ '.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected]': {
178
+ background: 'var(--dx-activeSurface)',
179
+ color: 'var(--dx-activeSurfaceText)',
180
+ },
181
+ '.cm-tooltip.cm-tooltip-autocomplete > ul > completion-section': {
182
+ paddingLeft: '4px !important',
183
+ color: 'var(--dx-hoverSurfaceText)',
184
+ },
185
+
186
+ /**
187
+ * Completion info.
188
+ */
189
+ '.cm-completionInfo': {
190
+ width: '360px !important',
191
+ margin: '-10px 1px 0 1px',
192
+ padding: '8px !important',
193
+ borderColor: 'var(--dx-separator)',
194
+ },
195
+ '.cm-completionIcon': {
196
+ display: 'none',
197
+ },
198
+ '.cm-completionLabel': {
199
+ color: 'var(--dx-description)',
200
+ padding: '0 4px',
201
+ },
202
+ '.cm-completionMatchedText': {
203
+ color: 'var(--dx-baseText)',
204
+ textDecoration: 'none !important',
205
+ },
206
+
207
+ /**
208
+ * Panels
209
+ * https://github.com/codemirror/search/blob/main/src/search.ts#L745
210
+ *
211
+ * Find/replace panel.
212
+ * <div class="cm-announced">...</div>
213
+ * <div class="cm-scroller">...</div>
214
+ * <div class="cm-panels cm-panels-bottom">
215
+ * <div class="cm-search cm-panel">
216
+ * <input class="cm-textfield" />
217
+ * <button class="cm-button">...</button>
218
+ * <label><input type="checkbox" />...</label>
219
+ * </div>
220
+ * </div
221
+ */
222
+ // TODO(burdon): Implement custom panel (with icon buttons).
223
+ '.cm-panels': {},
224
+ '.cm-panel': {
225
+ backgroundColor: 'var(--surface-bg)',
226
+ },
227
+ '.cm-panel input, .cm-panel button, .cm-panel label': {
228
+ color: 'var(--dx-subdued)',
229
+ fontSize: '14px',
230
+ all: 'unset',
231
+ margin: '3px !important',
232
+ padding: '2px 6px !important',
233
+ outline: '1px solid transparent',
234
+ },
235
+ '.cm-panel input, .cm-panel button': {
236
+ backgroundColor: 'var(--dx-inputSurface)',
237
+ },
238
+ '.cm-panel input:focus, .cm-panel button:focus': {
239
+ outline: '1px solid var(--dx-neutralFocusIndicator)',
240
+ },
241
+ '.cm-panel label': {
242
+ display: 'inline-flex',
243
+ alignItems: 'center',
244
+ cursor: 'pointer',
245
+ },
246
+ '.cm-panel input.cm-textfield': {},
247
+ '.cm-panel input[type=checkbox]': {
248
+ width: '8px',
249
+ height: '8px',
250
+ marginRight: '6px !important',
251
+ padding: '2px !important',
252
+ color: 'var(--dx-neutralFocusIndicator)',
253
+ },
254
+ '.cm-panel button': {
255
+ '&:hover': {
256
+ backgroundColor: 'var(--dx-accentSurfaceHover) !important',
257
+ },
258
+ '&:active': {
259
+ backgroundColor: 'var(--dx-accentSurfaceHover)',
260
+ },
261
+ },
262
+ '.cm-panel.cm-search': {
263
+ padding: '4px',
264
+ borderTop: '1px solid var(--dx-separator)',
265
+ },
266
+ });
267
+
268
+ export const editorGutter: Extension = EditorView.theme({
269
+ '.cm-gutters': {
270
+ // NOTE: Non-transparent background required to cover content if scrolling horizontally.
271
+ background: 'var(--dx-baseSurface) !important',
272
+ paddingRight: '1rem',
273
+ },
274
+ });
275
+
276
+ export type FontOptions = {
277
+ monospace?: boolean;
278
+ };
279
+
280
+ export const createFontTheme = ({ monospace }: FontOptions = {}) =>
281
+ EditorView.theme({
282
+ // Set metrics on the scroller (this is often what CM uses for layout).
283
+ '.cm-scroller': {
284
+ fontFamily: monospace ? fontMono : fontBody,
285
+ fontSize: '16px',
286
+ },
287
+
288
+ // Maintain defaults for UI components.
289
+ '.cm-content, .cm-gutters, .cm-panel': {
290
+ fontFamily: 'inherit',
291
+ fontSize: 'inherit',
292
+ },
293
+ });
@@ -0,0 +1,17 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { tokens } from '@dxos/ui-theme';
6
+ import { get } from '@dxos/util';
7
+
8
+ /**
9
+ * Returns the tailwind token value.
10
+ */
11
+ const getToken = (path: string, defaultValue?: string | string[]): string => {
12
+ const value = get(tokens, path, defaultValue);
13
+ return value?.toString() ?? '';
14
+ };
15
+
16
+ export const fontBody = getToken('fontFamily.body');
17
+ export const fontMono = getToken('fontFamily.mono');
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from './types';
@@ -0,0 +1,32 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { type EditorView } from '@codemirror/view';
6
+ import * as Schema from 'effect/Schema';
7
+
8
+ // Runtime data structure.
9
+ export type Range = {
10
+ from: number;
11
+ to: number;
12
+ };
13
+
14
+ // Persistent data structure.
15
+ // TODO(burdon): Rename annotation?
16
+ export type Comment = {
17
+ id: string;
18
+ cursor?: string;
19
+ };
20
+
21
+ /**
22
+ * Callback that renders into a DOM element within the editor.
23
+ */
24
+ export type RenderCallback<Props extends object> = (el: HTMLElement, props: Props, view: EditorView) => void;
25
+
26
+ export const EditorViewModes = ['preview', 'readonly', 'source'] as const;
27
+ export const EditorViewMode = Schema.Union(...EditorViewModes.map((mode) => Schema.Literal(mode)));
28
+ export type EditorViewMode = Schema.Schema.Type<typeof EditorViewMode>;
29
+
30
+ export const EditorInputModes = ['default', 'vim', 'vscode'] as const;
31
+ export const EditorInputMode = Schema.Union(...EditorInputModes.map((mode) => Schema.Literal(mode)));
32
+ export type EditorInputMode = Schema.Schema.Type<typeof EditorInputMode>;
@@ -0,0 +1,56 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { type EditorState } from '@codemirror/state';
6
+
7
+ import { type Range } from '../types';
8
+
9
+ import { singleValueFacet } from './facet';
10
+
11
+ /**
12
+ * Determines if two ranges overlap.
13
+ * A range is considered to overlap if there is any intersection
14
+ * between the two ranges, inclusive of their boundaries.
15
+ */
16
+ export const overlap = (a: Range, b: Range): boolean => a.from <= b.to && a.to >= b.from;
17
+
18
+ /**
19
+ * Converts indexes into the text document into stable peer-independent cursors.
20
+ *
21
+ * See:
22
+ * - https://automerge.org/automerge/api-docs/js/functions/next.getCursor.html
23
+ * - https://github.com/yjs/yjs?tab=readme-ov-file#relative-positions
24
+ *
25
+ * @param {assoc} number Negative values will associate the cursor with the previous character
26
+ * while positive - with the next one.
27
+ */
28
+ export interface CursorConverter {
29
+ toCursor(position: number, assoc?: -1 | 1 | undefined): string;
30
+ fromCursor(cursor: string): number;
31
+ }
32
+
33
+ const defaultCursorConverter: CursorConverter = {
34
+ toCursor: (position) => position.toString(),
35
+ fromCursor: (cursor) => parseInt(cursor),
36
+ };
37
+
38
+ export class Cursor {
39
+ static readonly converter = singleValueFacet(defaultCursorConverter);
40
+
41
+ static readonly getCursorFromRange = (state: EditorState, range: Range) => {
42
+ const cursorConverter = state.facet(Cursor.converter);
43
+ const from = cursorConverter.toCursor(range.from);
44
+ const to = cursorConverter.toCursor(range.to, -1);
45
+ return [from, to].join(':');
46
+ };
47
+
48
+ static readonly getRangeFromCursor = (state: EditorState, cursor: string) => {
49
+ const cursorConverter = state.facet(Cursor.converter);
50
+
51
+ const parts = cursor.split(':');
52
+ const from = cursorConverter.fromCursor(parts[0]);
53
+ const to = cursorConverter.fromCursor(parts[1]);
54
+ return from !== undefined && to !== undefined ? { from, to } : undefined;
55
+ };
56
+ }
@@ -0,0 +1,56 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { type Transaction } from '@codemirror/state';
6
+ import { type EditorView } from '@codemirror/view';
7
+
8
+ import { log } from '@dxos/log';
9
+
10
+ export const join = (...lines: string[]) => lines.join('\n');
11
+
12
+ /**
13
+ * CodeMirror callbacks swallow errors so wrap handlers.
14
+ */
15
+ export const wrapWithCatch = (fn: (...args: any[]) => any, label?: string) => {
16
+ return (...args: any[]) => {
17
+ try {
18
+ return fn(...args);
19
+ } catch (err) {
20
+ log.catch(err, { label });
21
+ }
22
+ };
23
+ };
24
+
25
+ /**
26
+ * Log all changes before dispatching them to the view.
27
+ * https://codemirror.net/docs/ref/#view.EditorView.dispatch
28
+ */
29
+ export const debugDispatcher = (trs: readonly Transaction[], view: EditorView) => {
30
+ logChanges(trs);
31
+ view.update(trs);
32
+ };
33
+
34
+ /**
35
+ * Util to log transactions in update listener.
36
+ */
37
+ export const logChanges = (trs: readonly Transaction[]) => {
38
+ const changes = trs
39
+ .flatMap((tr) => {
40
+ if (tr.changes.empty) {
41
+ return undefined;
42
+ }
43
+
44
+ const changes: any[] = [];
45
+ tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) =>
46
+ changes.push(JSON.stringify({ fromA, toA, fromB, toB, inserted: inserted.toString() })),
47
+ );
48
+
49
+ return changes;
50
+ })
51
+ .filter(Boolean);
52
+
53
+ if (changes.length) {
54
+ log('changes', { changes });
55
+ }
56
+ };
@@ -0,0 +1,21 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type Range } from '@codemirror/state';
6
+ import { type Decoration, type DecorationSet } from '@codemirror/view';
7
+
8
+ export const decorationSetToArray = (deco: DecorationSet): readonly Range<Decoration>[] => {
9
+ const ranges: Range<Decoration>[] = [];
10
+ const iter = deco.iter();
11
+ while (iter.value) {
12
+ ranges.push({
13
+ from: iter.from,
14
+ to: iter.to,
15
+ value: iter.value,
16
+ });
17
+ iter.next();
18
+ }
19
+
20
+ return ranges;
21
+ };
@@ -0,0 +1,36 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ // TODO(burdon): Factor out to @dxos/ui
6
+
7
+ export type Rect = {
8
+ readonly left: number;
9
+ readonly right: number;
10
+ readonly top: number;
11
+ readonly bottom: number;
12
+ };
13
+
14
+ export const flattenRect = (rect: Rect, left: boolean): Rect => {
15
+ const x = left ? rect.left : rect.right;
16
+ return { left: x, right: x, top: rect.top, bottom: rect.bottom };
17
+ };
18
+
19
+ let scratchRange: Range | null;
20
+
21
+ export const textRange = (node: Text, from: number, to = from): Range => {
22
+ const range = scratchRange || (scratchRange = document.createRange());
23
+ range.setEnd(node, to);
24
+ range.setStart(node, from);
25
+ return range;
26
+ };
27
+
28
+ export const clientRectsFor = (dom: Node): DOMRectList => {
29
+ if (dom.nodeType === 3) {
30
+ return textRange(dom as Text, 0, dom.nodeValue!.length).getClientRects();
31
+ } else if (dom.nodeType === 1) {
32
+ return (dom as HTMLElement).getClientRects();
33
+ } else {
34
+ return [] as any as DOMRectList;
35
+ }
36
+ };
@@ -0,0 +1,13 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { Facet } from '@codemirror/state';
6
+
7
+ export const singleValueFacet = <I, O = I>(defaultValue?: O) =>
8
+ Facet.define<I, O>({
9
+ // Called immediately.
10
+ combine: (providers) => {
11
+ return (providers[0] ?? defaultValue) as O;
12
+ },
13
+ });
@@ -0,0 +1,10 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ export * from './cursor';
6
+ export * from './decorations';
7
+ export * from './debug';
8
+ export * from './dom';
9
+ export * from './facet';
10
+ export * from './util';
@@ -0,0 +1,29 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type EditorView } from '@codemirror/view';
6
+
7
+ export const insertAtCursor = (view: EditorView, from: number, insert: string) => {
8
+ view.dispatch({
9
+ changes: { from, to: from, insert },
10
+ selection: { anchor: from + insert.length, head: from + insert.length },
11
+ });
12
+ };
13
+
14
+ /**
15
+ * If the cursor is at the start of a line, insert the text at the cursor.
16
+ * Otherwise, insert the text on a new line.
17
+ */
18
+ export const insertAtLineStart = (view: EditorView, from: number, insert: string) => {
19
+ const line = view.state.doc.lineAt(from);
20
+ if (line.from === from) {
21
+ insertAtCursor(view, from, insert);
22
+ } else {
23
+ insert = '\n' + insert;
24
+ view.dispatch({
25
+ changes: { from: line.to, to: line.to, insert },
26
+ selection: { anchor: line.to + insert.length, head: line.to + insert.length },
27
+ });
28
+ }
29
+ };