@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,284 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
6
+ import { defaultKeymap, history, historyKeymap, indentWithTab, standardKeymap } from '@codemirror/commands';
7
+ import { HighlightStyle, bracketMatching, syntaxHighlighting } from '@codemirror/language';
8
+ import { searchKeymap } from '@codemirror/search';
9
+ import { type ChangeSpec, EditorState, type Extension, type TransactionSpec } from '@codemirror/state';
10
+ import {
11
+ EditorView,
12
+ type KeyBinding,
13
+ ViewPlugin,
14
+ drawSelection,
15
+ dropCursor,
16
+ highlightActiveLine,
17
+ keymap,
18
+ lineNumbers,
19
+ placeholder,
20
+ scrollPastEnd,
21
+ } from '@codemirror/view';
22
+ import { vscodeDarkStyle, vscodeLightStyle } from '@uiw/codemirror-theme-vscode';
23
+ import defaultsDeep from 'lodash.defaultsdeep';
24
+
25
+ import { generateName } from '@dxos/display-name';
26
+ import { type DocAccessor } from '@dxos/echo-db';
27
+ import { log } from '@dxos/log';
28
+ import { type Messenger } from '@dxos/protocols';
29
+ import { type Identity } from '@dxos/protocols/proto/dxos/client/services';
30
+ import { type HuePalette } from '@dxos/ui-theme';
31
+ import { type ThemeMode } from '@dxos/ui-types';
32
+ import { hexToHue, isTruthy } from '@dxos/util';
33
+
34
+ import { baseTheme, createFontTheme, editorGutter } from '../styles';
35
+
36
+ import { automerge } from './automerge';
37
+ import { SpaceAwarenessProvider, awareness } from './awareness';
38
+ import { focus } from './focus';
39
+
40
+ //
41
+ // Basic
42
+ //
43
+
44
+ /**
45
+ * Enable tabbing into editor (required for tabster to work).
46
+ */
47
+ export const tabbable = EditorView.contentAttributes.of({ tabindex: '0' });
48
+
49
+ export const filterChars = (chars: RegExp) => {
50
+ return EditorState.transactionFilter.of((transaction) => {
51
+ if (!transaction.docChanged) {
52
+ return transaction;
53
+ }
54
+
55
+ const changes: ChangeSpec[] = [];
56
+ transaction.changes.iterChanges((fromA, toA, fromB, toB, text) => {
57
+ const inserted = text.toString();
58
+ const filtered = inserted.replace(chars, '');
59
+ if (inserted !== filtered) {
60
+ changes.push({
61
+ from: fromB,
62
+ to: toB,
63
+ insert: filtered,
64
+ });
65
+ }
66
+ });
67
+
68
+ if (changes.length) {
69
+ return [transaction, { changes, sequential: true } as TransactionSpec];
70
+ }
71
+
72
+ return transaction;
73
+ });
74
+ };
75
+
76
+ /**
77
+ * https://codemirror.net/docs/extensions
78
+ * https://github.com/codemirror/basic-setup
79
+ * https://github.com/codemirror/basic-setup/blob/main/src/codemirror.ts
80
+ * https://github.com/codemirror/theme-one-dark
81
+ */
82
+ export type BasicExtensionsOptions = {
83
+ allowMultipleSelections?: boolean;
84
+ bracketMatching?: boolean;
85
+ closeBrackets?: boolean;
86
+ dropCursor?: boolean;
87
+ drawSelection?: boolean;
88
+ editable?: boolean;
89
+ focus?: boolean;
90
+ highlightActiveLine?: boolean;
91
+ history?: boolean;
92
+ indentWithTab?: boolean;
93
+ keymap?: null | 'default' | 'standard';
94
+ lineNumbers?: boolean;
95
+ /** If false then do not set a max-width or side margin on the editor. */
96
+ lineWrapping?: boolean;
97
+ placeholder?: string;
98
+ /** If true user cannot edit the text, but they can still select and copy it. */
99
+ readOnly?: boolean;
100
+ search?: boolean;
101
+ /** NOTE: Do not use with stack sections. */
102
+ scrollPastEnd?: boolean;
103
+ standardKeymap?: boolean;
104
+ tabbable?: boolean;
105
+ tabSize?: number;
106
+ };
107
+
108
+ const defaultBasicOptions: BasicExtensionsOptions = {
109
+ allowMultipleSelections: true,
110
+ bracketMatching: true,
111
+ closeBrackets: true,
112
+ drawSelection: true,
113
+ focus: true,
114
+ history: true,
115
+ keymap: 'standard',
116
+ lineWrapping: true,
117
+ search: false,
118
+ } as const;
119
+
120
+ const keymaps: { [key: string]: readonly KeyBinding[] } = {
121
+ // https://codemirror.net/docs/ref/#commands.standardKeymap
122
+ standard: standardKeymap,
123
+ // https://codemirror.net/docs/ref/#commands.defaultKeymap
124
+ default: defaultKeymap,
125
+ };
126
+
127
+ export const createBasicExtensions = (propsProp?: BasicExtensionsOptions): Extension => {
128
+ const props = defaultsDeep({}, propsProp, defaultBasicOptions);
129
+ return [
130
+ // NOTE: Doesn't catch errors in keymap functions.
131
+ EditorView.exceptionSink.of((err) => {
132
+ log.catch(err);
133
+ }),
134
+
135
+ props.allowMultipleSelections && EditorState.allowMultipleSelections.of(true),
136
+ props.bracketMatching && bracketMatching(),
137
+ props.closeBrackets && closeBrackets(),
138
+ props.dropCursor && dropCursor(),
139
+ props.drawSelection && drawSelection({ cursorBlinkRate: 1_200 }),
140
+ props.editable !== undefined && EditorView.editable.of(props.editable),
141
+ props.focus && focus,
142
+ props.highlightActiveLine && highlightActiveLine(),
143
+ props.history && history(),
144
+ props.lineNumbers && [lineNumbers(), editorGutter],
145
+ props.lineWrapping && EditorView.lineWrapping,
146
+ props.placeholder && placeholder(props.placeholder),
147
+ props.readOnly !== undefined && EditorState.readOnly.of(props.readOnly),
148
+ props.scrollPastEnd && scrollPastEnd(),
149
+ props.tabbable && tabbable,
150
+ props.tabSize && EditorState.tabSize.of(props.tabSize),
151
+
152
+ // https://codemirror.net/docs/ref/#view.KeyBinding
153
+ keymap.of(
154
+ [
155
+ ...((props.keymap && keymaps[props.keymap]) ?? []),
156
+ // NOTE: Tabs are also configured by markdown extension.
157
+ // https://codemirror.net/docs/ref/#commands.indentWithTab
158
+ ...(props.indentWithTab ? [indentWithTab] : []),
159
+ // https://codemirror.net/docs/ref/#autocomplete.closeBracketsKeymap
160
+ ...(props.closeBrackets ? closeBracketsKeymap : []),
161
+ // https://codemirror.net/docs/ref/#commands.historyKeymap
162
+ ...(props.history ? historyKeymap : []),
163
+ // https://codemirror.net/docs/ref/#search.searchKeymap
164
+ ...(props.search ? searchKeymap : []),
165
+ // Disable bindings that conflict with system shortcuts.
166
+ // TODO(burdon): Catalog global shortcuts.
167
+ {
168
+ key: 'Mod-Shift-k',
169
+ preventDefault: true,
170
+ run: () => true,
171
+ },
172
+ ].filter(isTruthy),
173
+ ),
174
+ ].filter(isTruthy);
175
+ };
176
+
177
+ //
178
+ // Theme
179
+ //
180
+
181
+ export type ThemeExtensionsOptions = {
182
+ monospace?: boolean;
183
+ themeMode?: ThemeMode;
184
+ slots?: {
185
+ editor?: {
186
+ className?: string;
187
+ };
188
+ scroll?: {
189
+ // NOTE: Do not apply vertical padding to scroll container.
190
+ className?: string;
191
+ };
192
+ content?: {
193
+ className?: string;
194
+ };
195
+ };
196
+ syntaxHighlighting?: boolean;
197
+ };
198
+
199
+ export const grow: ThemeExtensionsOptions['slots'] = {
200
+ editor: {
201
+ className: 'bs-full is-full',
202
+ },
203
+ } as const;
204
+
205
+ export const fullWidth: ThemeExtensionsOptions['slots'] = {
206
+ editor: {
207
+ className: 'is-full',
208
+ },
209
+ } as const;
210
+
211
+ export const defaultThemeSlots = grow;
212
+
213
+ export const defaultStyles = {
214
+ dark: vscodeDarkStyle,
215
+ light: vscodeLightStyle,
216
+ };
217
+
218
+ /**
219
+ * https://codemirror.net/examples/styling
220
+ */
221
+ export const createThemeExtensions = ({
222
+ monospace,
223
+ themeMode,
224
+ slots: slotsProp,
225
+ syntaxHighlighting: syntaxHighlightingProp,
226
+ }: ThemeExtensionsOptions = {}): Extension => {
227
+ const slots = defaultsDeep({}, slotsProp, defaultThemeSlots);
228
+ return [
229
+ baseTheme,
230
+ EditorView.darkTheme.of(themeMode === 'dark'),
231
+ createFontTheme({ monospace }),
232
+ syntaxHighlightingProp &&
233
+ syntaxHighlighting(HighlightStyle.define(themeMode === 'dark' ? defaultStyles.dark : defaultStyles.light)),
234
+ slots.editor?.className && EditorView.editorAttributes.of({ class: slots.editor.className }),
235
+ slots.content?.className && EditorView.contentAttributes.of({ class: slots.content.className }),
236
+ slots.scroll?.className &&
237
+ ViewPlugin.fromClass(
238
+ class {
239
+ constructor(view: EditorView) {
240
+ view.scrollDOM.classList.add(...slots.scroll.className.split(/\s+/));
241
+ }
242
+ },
243
+ ),
244
+ ].filter(isTruthy);
245
+ };
246
+
247
+ //
248
+ // Data
249
+ //
250
+
251
+ export type DataExtensionsProps<T> = {
252
+ id: string;
253
+ text?: DocAccessor<T>;
254
+ messenger?: Messenger;
255
+ identity?: Identity | null;
256
+ };
257
+
258
+ export const createDataExtensions = <T>({ id, text, messenger, identity }: DataExtensionsProps<T>): Extension[] => {
259
+ const extensions: Extension[] = [];
260
+ if (text) {
261
+ extensions.push(automerge(text));
262
+ }
263
+
264
+ if (messenger && identity) {
265
+ const peerId = identity?.identityKey.toHex();
266
+ const hue = (identity?.profile?.data?.hue as HuePalette | undefined) ?? hexToHue(peerId ?? '0');
267
+ extensions.push(
268
+ awareness(
269
+ new SpaceAwarenessProvider({
270
+ messenger,
271
+ channel: `awareness.${id}`,
272
+ peerId: identity.identityKey.toHex(),
273
+ info: {
274
+ darkColor: `var(--dx-${hue}Cursor)`,
275
+ lightColor: `var(--dx-${hue}Cursor)`,
276
+ displayName: identity.profile?.displayName ?? generateName(identity.identityKey.toHex()),
277
+ },
278
+ }),
279
+ ),
280
+ );
281
+ }
282
+
283
+ return extensions;
284
+ };
@@ -0,0 +1,36 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { StateEffect, StateField } from '@codemirror/state';
6
+ import { EditorView } from '@codemirror/view';
7
+
8
+ const focusEffect = StateEffect.define<boolean>();
9
+
10
+ export const focusField = StateField.define<boolean>({
11
+ create: () => false,
12
+ update: (value, tr) => {
13
+ for (const effect of tr.effects) {
14
+ if (effect.is(focusEffect)) {
15
+ return effect.value;
16
+ }
17
+ }
18
+
19
+ return value;
20
+ },
21
+ });
22
+
23
+ /**
24
+ * Manage focus.
25
+ */
26
+ export const focus = [
27
+ focusField,
28
+ EditorView.domEventHandlers({
29
+ focus: (_event, view) => {
30
+ requestAnimationFrame(() => view.dispatch({ effects: focusEffect.of(true) }));
31
+ },
32
+ blur: (_event, view) => {
33
+ requestAnimationFrame(() => view.dispatch({ effects: focusEffect.of(false) }));
34
+ },
35
+ }),
36
+ ];
@@ -0,0 +1,63 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { codeFolding, foldGutter } from '@codemirror/language';
6
+ import { type Extension } from '@codemirror/state';
7
+ import { EditorView } from '@codemirror/view';
8
+
9
+ import { Domino, mx } from '@dxos/ui';
10
+
11
+ /**
12
+ * https://codemirror.net/examples/gutter
13
+ */
14
+ export const folding = (): Extension => {
15
+ return [
16
+ codeFolding({
17
+ placeholderDOM: () => Domino.of('span').root,
18
+ }),
19
+ foldGutter({
20
+ markerDOM: (open) => {
21
+ return Domino.of('div')
22
+ .classNames('flex bs-full justify-center items-center')
23
+ .children(
24
+ Domino.of('svg', Domino.SVG)
25
+ .classNames(mx('is-4 bs-4 cursor-pointer', open && 'rotate-90'))
26
+ .children(
27
+ Domino.of('use', Domino.SVG).attributes({
28
+ href: Domino.icon('ph--caret-right--regular'),
29
+ }),
30
+ ),
31
+ ).root;
32
+ },
33
+ // TODO(burdon): markerDOM is called either way, defeating the animation: transition-transform duration-200
34
+ // domEventHandlers: {
35
+ // click: (view, line: BlockInfo, event) => {
36
+ // event.preventDefault();
37
+ // event.stopPropagation();
38
+ // const range = foldable(view.state, line.from, line.to);
39
+ // if (range) {
40
+ // view.dispatch({ effects: foldEffect.of(range) });
41
+ // (event.target as HTMLElement)?.classList.add('rotate-90');
42
+ // } else {
43
+ // foldedRanges(view.state).between(line.from, line.to, (from, to) => {
44
+ // view.dispatch({ effects: unfoldEffect.of({ from, to }) });
45
+ // (event.target as HTMLElement)?.classList.remove('rotate-90');
46
+ // });
47
+ // }
48
+ // return true;
49
+ // },
50
+ // },
51
+ }),
52
+ EditorView.theme({
53
+ '.cm-foldGutter': {
54
+ opacity: 0.3,
55
+ transition: 'opacity 0.3s',
56
+ width: '32px',
57
+ },
58
+ '.cm-foldGutter:hover': {
59
+ opacity: 1,
60
+ },
61
+ }),
62
+ ];
63
+ };
@@ -0,0 +1,68 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type Extension } from '@codemirror/state';
6
+ import {
7
+ Decoration,
8
+ type DecorationSet,
9
+ EditorView,
10
+ MatchDecorator,
11
+ ViewPlugin,
12
+ type ViewUpdate,
13
+ WidgetType,
14
+ } from '@codemirror/view';
15
+
16
+ import { getHashStyles, mx } from '@dxos/ui-theme';
17
+
18
+ class TagWidget extends WidgetType {
19
+ constructor(private _text: string) {
20
+ super();
21
+ }
22
+
23
+ toDOM(): HTMLSpanElement {
24
+ const span = document.createElement('span');
25
+ span.className = mx('cm-tag', getHashStyles(this._text).surface);
26
+ span.textContent = this._text;
27
+ return span;
28
+ }
29
+ }
30
+
31
+ const tagMatcher = new MatchDecorator({
32
+ regexp: /#(\w+)\W/g,
33
+ decoration: (match) =>
34
+ Decoration.replace({
35
+ widget: new TagWidget(match[1]),
36
+ }),
37
+ });
38
+
39
+ // TODO(burdon): Autocomplete from existing tags?
40
+ export const hashtag = (): Extension => [
41
+ ViewPlugin.fromClass(
42
+ class {
43
+ tags: DecorationSet;
44
+ constructor(view: EditorView) {
45
+ this.tags = tagMatcher.createDeco(view);
46
+ }
47
+
48
+ update(update: ViewUpdate) {
49
+ this.tags = tagMatcher.updateDeco(update, this.tags);
50
+ }
51
+ },
52
+ {
53
+ decorations: (instance) => instance.tags,
54
+ provide: (plugin) =>
55
+ EditorView.atomicRanges.of((view) => {
56
+ return view.plugin(plugin)?.tags || Decoration.none;
57
+ }),
58
+ },
59
+ ),
60
+
61
+ EditorView.theme({
62
+ '.cm-tag': {
63
+ borderRadius: '4px',
64
+ marginRight: '6px',
65
+ padding: '2px 6px',
66
+ },
67
+ }),
68
+ ];
@@ -0,0 +1,34 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ export * from './annotations';
6
+ export * from './autocomplete';
7
+ export * from './autoscroll';
8
+ export * from './automerge';
9
+ export * from './awareness';
10
+ export * from './blast';
11
+ export * from './blocks';
12
+ export * from './bookmarks';
13
+ export * from './comments';
14
+ export * from './debug';
15
+ export * from './dnd';
16
+ export * from './factories';
17
+ export * from './focus';
18
+ export * from './folding';
19
+ export * from './hashtag';
20
+ export * from './json';
21
+ export * from './listener';
22
+ export * from './markdown';
23
+ export * from './mention';
24
+ export * from './modal';
25
+ export * from './modes';
26
+ export * from './outliner';
27
+ export * from './preview';
28
+ export * from './replacer';
29
+ export * from './selection';
30
+ export * from './scrolling';
31
+ export * from './state';
32
+ export * from './submit';
33
+ export * from './tags';
34
+ export * from './typewriter';
@@ -0,0 +1,57 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { json, jsonParseLinter } from '@codemirror/lang-json';
6
+ import { type LintSource, linter } from '@codemirror/lint';
7
+ import { type Extension } from '@codemirror/state';
8
+ import Ajv, { type ValidateFunction } from 'ajv';
9
+
10
+ import { type JsonSchemaType } from '@dxos/echo/internal';
11
+
12
+ export type JsonExtensionsOptions = {
13
+ schema?: JsonSchemaType;
14
+ };
15
+
16
+ export const createJsonExtensions = ({ schema }: JsonExtensionsOptions = {}): Extension => {
17
+ let lintSource: LintSource = jsonParseLinter();
18
+ if (schema) {
19
+ // NOTE: Relaxing strict mode to allow additional custom schema properties.
20
+ const ajv = new Ajv({ allErrors: false, strict: false });
21
+ const validate = ajv.compile(schema);
22
+ lintSource = schemaLinter(validate);
23
+ }
24
+
25
+ return [json(), linter(lintSource)];
26
+ };
27
+
28
+ const schemaLinter =
29
+ (validate: ValidateFunction): LintSource =>
30
+ (view) => {
31
+ try {
32
+ const jsonText = view.state.doc.toString();
33
+ const jsonData = JSON.parse(jsonText);
34
+ const valid = validate(jsonData);
35
+ if (valid) {
36
+ return [];
37
+ }
38
+
39
+ return (
40
+ validate.errors?.map((err: any) => ({
41
+ from: 0,
42
+ to: jsonText.length,
43
+ severity: 'error',
44
+ message: `${err.instancePath || '(root)'} ${err.message}`,
45
+ })) ?? []
46
+ );
47
+ } catch (err: unknown) {
48
+ return [
49
+ {
50
+ from: 0,
51
+ to: view.state.doc.length,
52
+ severity: 'error',
53
+ message: 'Invalid JSON: ' + (err as Error).message,
54
+ },
55
+ ];
56
+ }
57
+ };
@@ -0,0 +1,32 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { type Extension } from '@codemirror/state';
6
+ import { EditorView } from '@codemirror/view';
7
+
8
+ import { isNonNullable } from '@dxos/util';
9
+
10
+ import { documentId } from './selection';
11
+
12
+ export type ListenerOptions = {
13
+ onFocus?: (event: { id: string; focusing: boolean }) => void;
14
+ onChange?: (event: { id: string; text: string }) => void;
15
+ };
16
+
17
+ export const listener = ({ onFocus, onChange }: ListenerOptions): Extension => {
18
+ return [
19
+ onFocus &&
20
+ EditorView.focusChangeEffect.of((state, focusing) => {
21
+ onFocus({ id: state.facet(documentId), focusing });
22
+ return null;
23
+ }),
24
+
25
+ onChange &&
26
+ EditorView.updateListener.of(({ state, docChanged }) => {
27
+ if (docChanged) {
28
+ onChange({ id: state.facet(documentId), text: state.doc.toString() });
29
+ }
30
+ }),
31
+ ].filter(isNonNullable);
32
+ };
@@ -0,0 +1,117 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { type EditorView } from '@codemirror/view';
6
+
7
+ import { type Node } from '@dxos/app-graph';
8
+ import { type MenuActionProperties } from '@dxos/ui-types';
9
+
10
+ import { createComment } from '../comments';
11
+
12
+ import {
13
+ Inline,
14
+ List,
15
+ addBlockquote,
16
+ addCodeblock,
17
+ addLink,
18
+ addList,
19
+ insertTable,
20
+ removeBlockquote,
21
+ removeCodeblock,
22
+ removeLink,
23
+ removeList,
24
+ setHeading,
25
+ setStyle,
26
+ toggleBlockquote,
27
+ toggleList,
28
+ toggleStyle,
29
+ } from './formatting';
30
+
31
+ export type PayloadType =
32
+ | 'view-mode'
33
+ | 'blockquote'
34
+ | 'strong'
35
+ | 'codeblock'
36
+ | 'comment'
37
+ | 'heading'
38
+ | 'image'
39
+ | 'emphasis'
40
+ | 'code'
41
+ | 'link'
42
+ | 'list-bullet'
43
+ | 'list-ordered'
44
+ | 'list-task'
45
+ | 'mention'
46
+ | 'prompt'
47
+ | 'search'
48
+ | 'strikethrough'
49
+ | 'table';
50
+
51
+ export type EditorActionPayload = {
52
+ type: PayloadType;
53
+ data?: any;
54
+ };
55
+
56
+ export type EditorAction = Node.Action<MenuActionProperties & EditorActionPayload>;
57
+
58
+ export type EditorPayloadHandler = (view: EditorView, payload: EditorActionPayload) => void;
59
+
60
+ export const processEditorPayload: EditorPayloadHandler = (view, { type, data }) => {
61
+ let inlineType, listType;
62
+ switch (type) {
63
+ case 'heading':
64
+ setHeading(parseInt(data))(view);
65
+ break;
66
+
67
+ case 'strong':
68
+ case 'emphasis':
69
+ case 'strikethrough':
70
+ case 'code':
71
+ inlineType =
72
+ type === 'strong'
73
+ ? Inline.Strong
74
+ : type === 'emphasis'
75
+ ? Inline.Emphasis
76
+ : type === 'strikethrough'
77
+ ? Inline.Strikethrough
78
+ : Inline.Code;
79
+ (typeof data === 'boolean' ? setStyle(inlineType, data) : toggleStyle(inlineType))(view);
80
+ break;
81
+
82
+ case 'list-ordered':
83
+ case 'list-bullet':
84
+ case 'list-task':
85
+ listType = type === 'list-ordered' ? List.Ordered : type === 'list-bullet' ? List.Bullet : List.Task;
86
+ (data === false ? removeList(listType) : data === true ? addList(listType) : toggleList(listType))(view);
87
+ break;
88
+
89
+ case 'blockquote':
90
+ (data === false ? removeBlockquote : data === true ? addBlockquote : toggleBlockquote)(view);
91
+ break;
92
+ case 'codeblock':
93
+ (data === false ? removeCodeblock : addCodeblock)(view);
94
+ break;
95
+ case 'table':
96
+ insertTable(view);
97
+ break;
98
+
99
+ case 'link':
100
+ (data === false ? removeLink : addLink())(view);
101
+ break;
102
+
103
+ case 'image':
104
+ addLink({ url: data, image: true })(view);
105
+ break;
106
+
107
+ case 'comment':
108
+ createComment(view);
109
+ break;
110
+ }
111
+
112
+ requestAnimationFrame(() => {
113
+ if (!view.hasFocus) {
114
+ view.focus();
115
+ }
116
+ });
117
+ };