@dxos/react-ui-editor 0.3.9-main.9ca5eac → 0.3.9-main.9da1d41

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 (149) hide show
  1. package/dist/lib/browser/index.mjs +654 -734
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/types/src/automerge/automerge-plugin/PatchSemaphore.d.ts +2 -2
  5. package/dist/types/src/automerge/automerge-plugin/PatchSemaphore.d.ts.map +1 -1
  6. package/dist/types/src/automerge/automerge-plugin/codeMirrorToAm.d.ts +2 -2
  7. package/dist/types/src/automerge/automerge-plugin/codeMirrorToAm.d.ts.map +1 -1
  8. package/dist/types/src/automerge/automerge-plugin/handle.d.ts +9 -0
  9. package/dist/types/src/automerge/automerge-plugin/handle.d.ts.map +1 -0
  10. package/dist/types/src/automerge/automerge-plugin/index.d.ts +2 -2
  11. package/dist/types/src/automerge/automerge-plugin/index.d.ts.map +1 -1
  12. package/dist/types/src/automerge/automerge-plugin/plugin.d.ts +6 -5
  13. package/dist/types/src/automerge/automerge-plugin/plugin.d.ts.map +1 -1
  14. package/dist/types/src/automerge/automerge.stories.d.ts +1 -0
  15. package/dist/types/src/automerge/automerge.stories.d.ts.map +1 -1
  16. package/dist/types/src/components/TextEditor/TextEditor.d.ts +24 -20
  17. package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
  18. package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts +2 -25
  19. package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts.map +1 -1
  20. package/dist/types/{stories/html/code-mirror.stories.d.ts → src/components/TextEditor/codemirror.stories.d.ts} +2 -2
  21. package/dist/types/src/components/TextEditor/codemirror.stories.d.ts.map +1 -0
  22. package/dist/types/src/components/TextEditor/extensions/basic/bundle.d.ts +8 -0
  23. package/dist/types/src/components/TextEditor/extensions/basic/bundle.d.ts.map +1 -0
  24. package/dist/types/src/components/TextEditor/extensions/basic/index.d.ts +3 -0
  25. package/dist/types/src/components/TextEditor/extensions/basic/index.d.ts.map +1 -0
  26. package/dist/types/src/components/TextEditor/extensions/basic/theme.d.ts +10 -0
  27. package/dist/types/src/components/TextEditor/extensions/basic/theme.d.ts.map +1 -0
  28. package/dist/types/src/components/TextEditor/extensions/change.d.ts +7 -0
  29. package/dist/types/src/components/TextEditor/extensions/change.d.ts.map +1 -0
  30. package/dist/types/src/components/TextEditor/extensions/hyperlink/hyperlinkDecoration.d.ts +11 -0
  31. package/dist/types/src/components/TextEditor/extensions/hyperlink/hyperlinkDecoration.d.ts.map +1 -0
  32. package/dist/types/src/components/TextEditor/extensions/hyperlink/hyperlinkTooltip.d.ts.map +1 -0
  33. package/dist/types/src/components/TextEditor/extensions/{hyperlink.d.ts → hyperlink/hyperlinkWidget.d.ts} +3 -3
  34. package/dist/types/src/components/TextEditor/extensions/hyperlink/hyperlinkWidget.d.ts.map +1 -0
  35. package/dist/types/src/components/TextEditor/extensions/hyperlink/index.d.ts +4 -0
  36. package/dist/types/src/components/TextEditor/extensions/hyperlink/index.d.ts.map +1 -0
  37. package/dist/types/src/components/TextEditor/extensions/index.d.ts +3 -1
  38. package/dist/types/src/components/TextEditor/extensions/index.d.ts.map +1 -1
  39. package/dist/types/src/components/TextEditor/extensions/markdown/bundle.d.ts +8 -0
  40. package/dist/types/src/components/TextEditor/extensions/markdown/bundle.d.ts.map +1 -0
  41. package/dist/types/src/components/TextEditor/extensions/markdown/index.d.ts +3 -0
  42. package/dist/types/src/components/TextEditor/extensions/markdown/index.d.ts.map +1 -0
  43. package/dist/types/src/components/{Markdown/markdownTags.d.ts → TextEditor/extensions/markdown/tags.d.ts} +1 -1
  44. package/dist/types/src/components/TextEditor/extensions/markdown/tags.d.ts.map +1 -0
  45. package/dist/types/src/components/TextEditor/extensions/markdown/theme.d.ts +21 -0
  46. package/dist/types/src/components/TextEditor/extensions/markdown/theme.d.ts.map +1 -0
  47. package/dist/types/src/components/TextEditor/index.d.ts +1 -3
  48. package/dist/types/src/components/TextEditor/index.d.ts.map +1 -1
  49. package/dist/types/src/components/TextEditor/yjs.stories.d.ts +11 -0
  50. package/dist/types/src/components/TextEditor/yjs.stories.d.ts.map +1 -0
  51. package/dist/types/src/components/index.d.ts +0 -3
  52. package/dist/types/src/components/index.d.ts.map +1 -1
  53. package/dist/types/src/hooks/index.d.ts +4 -0
  54. package/dist/types/src/hooks/index.d.ts.map +1 -0
  55. package/dist/types/src/hooks/useCollaboration.d.ts +9 -0
  56. package/dist/types/src/hooks/useCollaboration.d.ts.map +1 -0
  57. package/dist/types/src/{model.d.ts → hooks/useTextModel.d.ts} +1 -13
  58. package/dist/types/src/hooks/useTextModel.d.ts.map +1 -0
  59. package/dist/types/src/hooks/yjs/cursors.d.ts.map +1 -0
  60. package/dist/types/src/hooks/yjs/index.d.ts.map +1 -0
  61. package/dist/types/src/hooks/yjs/space-provider.d.ts.map +1 -0
  62. package/dist/types/src/index.d.ts +2 -2
  63. package/dist/types/src/index.d.ts.map +1 -1
  64. package/dist/types/src/styles/tokens.d.ts +4 -0
  65. package/dist/types/src/styles/tokens.d.ts.map +1 -1
  66. package/dist/types/src/testing/replicator.d.ts +1 -1
  67. package/dist/types/src/testing/replicator.d.ts.map +1 -1
  68. package/package.json +30 -27
  69. package/src/automerge/automerge-plugin/PatchSemaphore.ts +5 -5
  70. package/src/automerge/automerge-plugin/codeMirrorToAm.ts +10 -7
  71. package/src/automerge/automerge-plugin/handle.ts +14 -0
  72. package/src/automerge/automerge-plugin/index.ts +2 -2
  73. package/src/automerge/automerge-plugin/plugin.ts +39 -10
  74. package/src/automerge/automerge.stories.tsx +31 -46
  75. package/src/components/TextEditor/TextEditor.stories.tsx +43 -65
  76. package/src/components/TextEditor/TextEditor.tsx +108 -56
  77. package/src/components/TextEditor/codemirror.stories.ts +114 -0
  78. package/src/components/TextEditor/extensions/basic/bundle.ts +26 -0
  79. package/src/components/TextEditor/extensions/basic/index.ts +6 -0
  80. package/src/components/TextEditor/extensions/basic/theme.ts +49 -0
  81. package/src/components/TextEditor/extensions/change.ts +22 -0
  82. package/src/components/TextEditor/extensions/hyperlink/hyperlinkDecoration.ts +99 -0
  83. package/src/components/TextEditor/extensions/{hyperlinkTooltip.tsx → hyperlink/hyperlinkTooltip.tsx} +1 -0
  84. package/src/components/TextEditor/extensions/{hyperlink.tsx → hyperlink/hyperlinkWidget.tsx} +21 -10
  85. package/src/components/TextEditor/extensions/hyperlink/index.ts +7 -0
  86. package/src/components/TextEditor/extensions/index.ts +3 -1
  87. package/src/components/TextEditor/extensions/markdown/bundle.ts +99 -0
  88. package/src/components/TextEditor/extensions/markdown/index.ts +6 -0
  89. package/src/components/{Markdown/markdownTheme.ts → TextEditor/extensions/markdown/theme.ts} +23 -12
  90. package/src/components/TextEditor/index.ts +1 -4
  91. package/src/components/TextEditor/yjs.stories.tsx +55 -0
  92. package/src/components/index.ts +0 -3
  93. package/src/hooks/index.ts +7 -0
  94. package/src/hooks/useCollaboration.ts +35 -0
  95. package/src/{model.ts → hooks/useTextModel.ts} +4 -14
  96. package/src/index.ts +3 -3
  97. package/src/styles/tokens.ts +6 -0
  98. package/src/testing/replicator.ts +1 -2
  99. package/dist/types/src/automerge/demo.d.ts +0 -25
  100. package/dist/types/src/automerge/demo.d.ts.map +0 -1
  101. package/dist/types/src/components/Editor/Editor.d.ts +0 -15
  102. package/dist/types/src/components/Editor/Editor.d.ts.map +0 -1
  103. package/dist/types/src/components/Editor/Editor.stories.d.ts +0 -29
  104. package/dist/types/src/components/Editor/Editor.stories.d.ts.map +0 -1
  105. package/dist/types/src/components/Editor/index.d.ts +0 -2
  106. package/dist/types/src/components/Editor/index.d.ts.map +0 -1
  107. package/dist/types/src/components/Markdown/Markdown.d.ts +0 -20
  108. package/dist/types/src/components/Markdown/Markdown.d.ts.map +0 -1
  109. package/dist/types/src/components/Markdown/Markdown.stories.d.ts +0 -20
  110. package/dist/types/src/components/Markdown/Markdown.stories.d.ts.map +0 -1
  111. package/dist/types/src/components/Markdown/index.d.ts +0 -2
  112. package/dist/types/src/components/Markdown/index.d.ts.map +0 -1
  113. package/dist/types/src/components/Markdown/markdownTags.d.ts.map +0 -1
  114. package/dist/types/src/components/Markdown/markdownTheme.d.ts +0 -93
  115. package/dist/types/src/components/Markdown/markdownTheme.d.ts.map +0 -1
  116. package/dist/types/src/components/RichText/RichText.d.ts +0 -20
  117. package/dist/types/src/components/RichText/RichText.d.ts.map +0 -1
  118. package/dist/types/src/components/RichText/RichText.stories.d.ts +0 -27
  119. package/dist/types/src/components/RichText/RichText.stories.d.ts.map +0 -1
  120. package/dist/types/src/components/RichText/index.d.ts +0 -2
  121. package/dist/types/src/components/RichText/index.d.ts.map +0 -1
  122. package/dist/types/src/components/TextEditor/extensions/hyperlink.d.ts.map +0 -1
  123. package/dist/types/src/components/TextEditor/extensions/hyperlinkTooltip.d.ts.map +0 -1
  124. package/dist/types/src/components/TextEditor/theme.d.ts +0 -8
  125. package/dist/types/src/components/TextEditor/theme.d.ts.map +0 -1
  126. package/dist/types/src/model.d.ts.map +0 -1
  127. package/dist/types/src/yjs/cursors.d.ts.map +0 -1
  128. package/dist/types/src/yjs/index.d.ts.map +0 -1
  129. package/dist/types/src/yjs/space-provider.d.ts.map +0 -1
  130. package/dist/types/stories/html/code-mirror.stories.d.ts.map +0 -1
  131. package/src/automerge/demo.ts +0 -106
  132. package/src/components/Editor/Editor.stories.tsx +0 -82
  133. package/src/components/Editor/Editor.tsx +0 -33
  134. package/src/components/Editor/index.ts +0 -5
  135. package/src/components/Markdown/Markdown.stories.tsx +0 -59
  136. package/src/components/Markdown/Markdown.tsx +0 -214
  137. package/src/components/Markdown/index.ts +0 -5
  138. package/src/components/RichText/RichText.stories.tsx +0 -70
  139. package/src/components/RichText/RichText.tsx +0 -181
  140. package/src/components/RichText/index.ts +0 -5
  141. package/src/components/TextEditor/theme.ts +0 -33
  142. /package/dist/types/src/components/TextEditor/extensions/{hyperlinkTooltip.d.ts → hyperlink/hyperlinkTooltip.d.ts} +0 -0
  143. /package/dist/types/src/{yjs → hooks/yjs}/cursors.d.ts +0 -0
  144. /package/dist/types/src/{yjs → hooks/yjs}/index.d.ts +0 -0
  145. /package/dist/types/src/{yjs → hooks/yjs}/space-provider.d.ts +0 -0
  146. /package/src/components/{Markdown/markdownTags.ts → TextEditor/extensions/markdown/tags.ts} +0 -0
  147. /package/src/{yjs → hooks/yjs}/cursors.ts +0 -0
  148. /package/src/{yjs → hooks/yjs}/index.ts +0 -0
  149. /package/src/{yjs → hooks/yjs}/space-provider.ts +0 -0
@@ -2,28 +2,29 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { closeBrackets } from '@codemirror/autocomplete';
6
- import { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language';
7
5
  import { EditorState, type Extension } from '@codemirror/state';
8
- import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
9
- import { EditorView, placeholder } from '@codemirror/view';
6
+ import { EditorView } from '@codemirror/view';
7
+ import { useFocusableGroup } from '@fluentui/react-tabster';
8
+ import { vim } from '@replit/codemirror-vim';
9
+ import defaultsDeep from 'lodash.defaultsdeep';
10
10
  import React, {
11
11
  type KeyboardEvent,
12
12
  forwardRef,
13
+ useCallback,
13
14
  useEffect,
14
15
  useImperativeHandle,
15
16
  useState,
16
- useCallback,
17
- type HTMLAttributes,
17
+ type ComponentProps,
18
18
  } from 'react';
19
- import { type StyleSpec } from 'style-mod';
20
- import { yCollab } from 'y-codemirror.next';
21
19
 
22
20
  import { useThemeContext } from '@dxos/react-ui';
23
- import { YText } from '@dxos/text-model';
24
21
 
25
- import { defaultStyles } from './theme';
26
- import { type EditorModel, type EditorSlots } from '../../model';
22
+ import { basicBundle, basicTheme, markdownBundle, markdownTheme } from './extensions';
23
+ import { type EditorModel, useCollaboration } from '../../hooks';
24
+ import type { ThemeStyles } from '../../styles';
25
+
26
+ export const EditorModes = ['default', 'vim'] as const;
27
+ export type EditorMode = (typeof EditorModes)[number];
27
28
 
28
29
  export type CursorInfo = {
29
30
  from: number;
@@ -39,71 +40,77 @@ export type TextEditorRef = {
39
40
  view?: EditorView;
40
41
  };
41
42
 
43
+ export type TextEditorSlots = {
44
+ root?: Omit<ComponentProps<'div'>, 'ref'>;
45
+ editor?: {
46
+ className?: string;
47
+ placeholder?: string;
48
+ spellCheck?: boolean;
49
+ tabIndex?: number;
50
+ theme?: ThemeStyles;
51
+ };
52
+ };
53
+
42
54
  export type TextEditorProps = {
43
- model?: EditorModel;
55
+ model: EditorModel;
44
56
  extensions?: Extension[];
45
- theme?: {
46
- [selector: string]: StyleSpec;
47
- };
48
- slots?: EditorSlots;
49
- onKeyDown?: (event: KeyboardEvent, info: CursorInfo) => void;
50
- } & Pick<HTMLAttributes<HTMLDivElement>, 'onBlur' | 'onFocus'>;
57
+ slots?: TextEditorSlots;
58
+ editorMode?: EditorMode;
59
+ };
51
60
 
52
61
  /**
53
- * Simple text editor.
62
+ * Base text editor.
63
+ * NOTE: Rather than adding properties, try to create extensions that can be reused.
54
64
  */
55
- export const TextEditor = forwardRef<TextEditorRef, TextEditorProps>(
56
- ({ model, extensions = [], theme = defaultStyles, slots = {}, onKeyDown, ...props }, forwardedRef) => {
57
- const { id, content } = model ?? {};
65
+ export const BaseTextEditor = forwardRef<TextEditorRef, TextEditorProps>(
66
+ ({ model, extensions = [], slots, editorMode }, forwardedRef) => {
58
67
  const { themeMode } = useThemeContext();
68
+ const tabsterDOMAttribute = useFocusableGroup({ tabBehavior: 'limited' });
59
69
 
60
70
  const [parent, setParent] = useState<HTMLDivElement | null>(null);
61
71
  const [state, setState] = useState<EditorState>();
62
72
  const [view, setView] = useState<EditorView>();
63
-
64
- // TODO(burdon): The ref may be instantiated before the view is created.
65
73
  useImperativeHandle(
66
74
  forwardedRef,
67
- () => {
68
- return {
69
- editor: parent,
70
- state,
71
- view,
72
- };
73
- },
74
- [view],
75
+ () => ({
76
+ editor: parent,
77
+ state,
78
+ view,
79
+ }),
80
+ [view, state, parent],
75
81
  );
76
82
 
83
+ // TODO(burdon): Pass in extension?
84
+ const collaboration = useCollaboration(model, themeMode);
85
+
77
86
  useEffect(() => {
78
87
  if (!parent) {
79
88
  return;
80
89
  }
81
90
 
82
- view?.destroy();
83
-
84
91
  const state = EditorState.create({
85
- doc: content?.toString(),
92
+ doc: model.content?.toString(),
86
93
  extensions: [
87
- bracketMatching(),
88
- closeBrackets(),
89
- placeholder(slots.editor?.placeholder ?? ''),
90
- EditorView.lineWrapping,
91
-
92
- // Themes.
93
- EditorView.theme(theme),
94
- ...(themeMode === 'dark'
95
- ? [syntaxHighlighting(oneDarkHighlightStyle)]
96
- : [syntaxHighlighting(defaultHighlightStyle)]),
94
+ // TODO(burdon): Factor out VIM mode?
95
+ editorMode === 'vim' && vim(),
97
96
 
98
97
  // Replication.
99
- ...(content instanceof YText ? [yCollab(content, undefined)] : []),
98
+ collaboration,
99
+
100
+ // Theme.
101
+ slots?.editor?.theme && EditorView.theme(slots?.editor?.theme),
100
102
 
101
103
  // Custom.
102
104
  ...extensions,
103
- ],
105
+ ].filter(Boolean) as Extension[],
104
106
  });
105
107
 
106
108
  setState(state);
109
+
110
+ // NOTE: This repaints the editor.
111
+ // If the new state is derived from the old state, it will likely not be visible other than the cursor resetting.
112
+ // Ideally this should not be hit except when changing between text objects.
113
+ view?.destroy();
107
114
  setView(new EditorView({ state, parent }));
108
115
 
109
116
  return () => {
@@ -111,20 +118,65 @@ export const TextEditor = forwardRef<TextEditorRef, TextEditorProps>(
111
118
  setView(undefined);
112
119
  setState(undefined);
113
120
  };
114
- }, [parent, content, themeMode]);
121
+ }, [parent, model.content, themeMode, editorMode]);
115
122
 
116
- const handleKeyDown = useCallback(
123
+ const handleKeyUp = useCallback(
117
124
  (event: KeyboardEvent) => {
118
- if (view) {
119
- const { head, from, to } = view.state.selection.ranges[0];
120
- const { number } = view.state.doc.lineAt(head);
121
- const after = view.state.sliceDoc(from);
122
- onKeyDown?.(event, { from, to, line: number, lines: view.state.doc.lines, after });
125
+ const { key, altKey, shiftKey, metaKey, ctrlKey } = event;
126
+ switch (key) {
127
+ case 'Enter': {
128
+ view?.contentDOM.focus();
129
+ break;
130
+ }
131
+
132
+ case 'Escape': {
133
+ editorMode === 'vim' && (altKey || shiftKey || metaKey || ctrlKey) && parent?.focus();
134
+ break;
135
+ }
123
136
  }
124
137
  },
125
- [view],
138
+ [view, editorMode],
139
+ );
140
+
141
+ return (
142
+ <div
143
+ key={model.id}
144
+ ref={setParent}
145
+ tabIndex={0}
146
+ {...slots?.root}
147
+ {...(editorMode !== 'vim' && tabsterDOMAttribute)}
148
+ onKeyUp={handleKeyUp}
149
+ />
126
150
  );
151
+ },
152
+ );
127
153
 
128
- return <div key={id} ref={setParent} {...slots.root} onKeyDown={handleKeyDown} {...props} />;
154
+ export const TextEditor = forwardRef<TextEditorRef, TextEditorProps>(
155
+ ({ extensions: _extensions, slots, ...props }, forwardedRef) => {
156
+ const { themeMode } = useThemeContext();
157
+ const extensions = [...(_extensions ?? []), basicBundle({ themeMode, placeholder: slots?.editor?.placeholder })];
158
+ return (
159
+ <BaseTextEditor
160
+ ref={forwardedRef}
161
+ extensions={extensions}
162
+ slots={defaultsDeep({}, slots, { editor: { theme: basicTheme } })}
163
+ {...props}
164
+ />
165
+ );
166
+ },
167
+ );
168
+
169
+ export const MarkdownEditor = forwardRef<TextEditorRef, TextEditorProps>(
170
+ ({ extensions: _extensions, slots, ...props }, forwardedRef) => {
171
+ const { themeMode } = useThemeContext();
172
+ const extensions = [...(_extensions ?? []), markdownBundle({ themeMode })];
173
+ return (
174
+ <BaseTextEditor
175
+ ref={forwardedRef}
176
+ extensions={extensions}
177
+ slots={defaultsDeep({}, slots, { editor: { theme: markdownTheme } })}
178
+ {...props}
179
+ />
180
+ );
129
181
  },
130
182
  );
@@ -0,0 +1,114 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
6
+ import { EditorState } from '@codemirror/state';
7
+ import { EditorView } from '@codemirror/view';
8
+ import { type StoryObj } from '@storybook/html';
9
+ import { basicSetup } from 'codemirror';
10
+ import { yCollab } from 'y-codemirror.next';
11
+
12
+ import { EventSubscriptions } from '@dxos/async';
13
+ import { generateName } from '@dxos/display-name';
14
+ import { Client, type PublicKey } from '@dxos/react-client';
15
+ import { type Space, TextObject } from '@dxos/react-client/echo';
16
+ import { type Identity } from '@dxos/react-client/halo';
17
+ import { joinCommonSpace, TestBuilder, textGenerator } from '@dxos/react-client/testing';
18
+ import { YText } from '@dxos/text-model';
19
+
20
+ import { cursorColor, SpaceAwarenessProvider } from '../../hooks';
21
+ import { EditorDocument, types as schema } from '../../testing';
22
+
23
+ export default {
24
+ title: 'CodeMirror',
25
+ };
26
+
27
+ const testBuilder = new TestBuilder();
28
+ const views: Record<number, EditorView> = {};
29
+ const subscriptions = new EventSubscriptions();
30
+
31
+ const updateEditor = async (id: number, editor: HTMLDivElement, identity: Identity, space: Space, text: TextObject) => {
32
+ const doc = text.doc;
33
+ const ytext = text.content;
34
+ if (!(ytext instanceof YText) || !doc) {
35
+ return;
36
+ }
37
+
38
+ const { awareness } = new SpaceAwarenessProvider({ space, doc, channel: `yjs.awareness.${text.id}` });
39
+ awareness.setLocalStateField('user', {
40
+ name: identity.profile?.displayName ?? generateName(identity.identityKey.toHex()),
41
+ // TODO(wittjosiah): Pick colours from theme based on identity key.
42
+ color: cursorColor.color,
43
+ colorLight: cursorColor.light,
44
+ });
45
+
46
+ const state = EditorState.create({
47
+ doc: ytext.toString(),
48
+ extensions: [basicSetup, markdown({ base: markdownLanguage }), yCollab(ytext, awareness)],
49
+ });
50
+
51
+ const view = views[id];
52
+ if (view) {
53
+ view.setState(state);
54
+ } else {
55
+ views[id] = new EditorView({ state, parent: editor });
56
+ }
57
+
58
+ if (id === 0) {
59
+ subscriptions.clear();
60
+ subscriptions.add(textGenerator({ text: ytext }));
61
+ }
62
+ };
63
+
64
+ const setupEditor = async (id: number, client: Client, spaceKey: PublicKey, editor: HTMLDivElement) => {
65
+ const space = await client.spaces.get(spaceKey)!;
66
+ const query = space.db.query(EditorDocument.filter());
67
+ query.subscribe(({ objects }) => {
68
+ const text = objects[0]?.content;
69
+ void (text && updateEditor(id, editor, client.halo.identity.get()!, space, text));
70
+ });
71
+
72
+ const text = query.objects[0]?.content;
73
+ void (text && updateEditor(id, editor, client.halo.identity.get()!, space, text));
74
+ };
75
+
76
+ const setupSpace = async (count: number) => {
77
+ const clients = [...Array(count)].map(() => new Client({ services: testBuilder.createLocal() }));
78
+ await Promise.all(clients.map((client) => client.initialize()));
79
+ await Promise.all(clients.map((client) => client.halo.createIdentity()));
80
+ clients.map((client) => client.spaces.addSchema(schema));
81
+
82
+ const space = await clients[0].spaces.create();
83
+ const text = new TextObject('Hello, Storybook!');
84
+ const document = new EditorDocument({ content: text });
85
+ await space.db.add(document);
86
+
87
+ await joinCommonSpace(clients, space.key);
88
+
89
+ return { clients, spaceKey: space.key };
90
+ };
91
+
92
+ const count = 2;
93
+
94
+ export const Default: StoryObj<{ id: number; client: Client; spaceKey: PublicKey }> = {
95
+ render: ({ id, client, spaceKey }) => {
96
+ const editor = document.createElement('div');
97
+ editor.setAttribute('style', 'min-width: 0; flex: 1; padding: 1rem;');
98
+ void setupEditor(id, client, spaceKey, editor);
99
+ return editor;
100
+ },
101
+ decorators: [
102
+ (story) => {
103
+ const container = document.createElement('div');
104
+ container.setAttribute('style', 'display: flex; justify-content: space-evenly');
105
+ void setupSpace(count).then(({ clients, spaceKey }) => {
106
+ clients.forEach((client, id) => {
107
+ // TODO(wittjosiah): According to the docs these types should be compatible.
108
+ container.appendChild(story({ args: { id, client, spaceKey } }) as HTMLElement);
109
+ });
110
+ });
111
+ return container;
112
+ },
113
+ ],
114
+ };
@@ -0,0 +1,26 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { closeBrackets } from '@codemirror/autocomplete';
6
+ import { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language';
7
+ import { type Extension } from '@codemirror/state';
8
+ import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
9
+ import { placeholder } from '@codemirror/view';
10
+
11
+ import type { ThemeMode } from '@dxos/react-ui';
12
+
13
+ export type BasicBundleOptions = {
14
+ themeMode?: ThemeMode;
15
+ placeholder?: string;
16
+ };
17
+
18
+ export const basicBundle = ({ themeMode, placeholder: _placeholder }: BasicBundleOptions): Extension[] =>
19
+ [
20
+ bracketMatching(),
21
+ closeBrackets(),
22
+ _placeholder && placeholder(_placeholder),
23
+
24
+ // TODO(burdon): Is this required?
25
+ themeMode === 'dark' ? syntaxHighlighting(oneDarkHighlightStyle) : syntaxHighlighting(defaultHighlightStyle),
26
+ ].filter(Boolean) as Extension[];
@@ -0,0 +1,6 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ export * from './bundle';
6
+ export * from './theme';
@@ -0,0 +1,49 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import get from 'lodash.get';
6
+ import { type StyleSpec } from 'style-mod';
7
+
8
+ import { tokens } from '../../../../styles';
9
+
10
+ /**
11
+ * Minimal styles.
12
+ * NOTE: The '&' prefix denotes the CM editor root.
13
+ * https://codemirror.net/examples/styling
14
+ */
15
+ // TODO(burdon): Remove?
16
+ export const basicTheme: {
17
+ [selector: string]: StyleSpec;
18
+ } = {
19
+ '&.cm-focused': {
20
+ outline: 'none',
21
+ },
22
+ '& .cm-line': {
23
+ paddingInline: 0,
24
+ lineHeight: 1.6,
25
+ minBlockSize: '1.6em',
26
+ },
27
+ '& .cm-line *': {
28
+ lineHeight: 1.6,
29
+ },
30
+ '& .cm-scroller': {
31
+ fontFamily: get(tokens, 'fontFamily.body', []).join(','),
32
+ overflow: 'visible',
33
+ },
34
+ '& .cm-content': {
35
+ padding: 0,
36
+ caretColor: 'black',
37
+ },
38
+ '.dark & .cm-content': {
39
+ caretColor: 'white',
40
+ },
41
+ '& .cm-tooltip': {
42
+ backgroundColor: 'transparent',
43
+ border: 'none',
44
+ },
45
+ '& .cm-link': {
46
+ color: get(tokens, 'extend.colors.primary.500'),
47
+ textDecoration: 'underline',
48
+ },
49
+ };
@@ -0,0 +1,22 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { StateField } from '@codemirror/state';
6
+
7
+ /**
8
+ * Based on https://github.com/codemirror/dev/issues/44#issuecomment-789093799
9
+ * @param onChange
10
+ */
11
+ // TODO(burdon): Name?
12
+ export const onChangeExtension = (onChange: (text: string) => void) =>
13
+ StateField.define({
14
+ create: () => null,
15
+ update: (_value, transaction) => {
16
+ if (transaction.docChanged && onChange) {
17
+ onChange(transaction.newDoc.toString());
18
+ }
19
+
20
+ return null;
21
+ },
22
+ });
@@ -0,0 +1,99 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ // Copyright CodeMirror
4
+ //
5
+
6
+ import { syntaxTree } from '@codemirror/language';
7
+ import { type EditorState, type RangeSet, RangeSetBuilder, StateField, type Transaction } from '@codemirror/state';
8
+ import { Decoration, EditorView, WidgetType } from '@codemirror/view';
9
+
10
+ // Adapted from:
11
+ // https://codemirror.net/try/?c=aW1wb3J0IHsgYmFzaWNTZXR1cCwgRWRpdG9yVmlldyB9IGZyb20gImNvZGVtaXJyb3IiCmltcG9ydCB7IG1hcmtkb3duIH0gZnJvbSAiQGNvZGVtaXJyb3IvbGFuZy1tYXJrZG93biIKaW1wb3J0IHsgc3ludGF4VHJlZSB9IGZyb20gIkBjb2RlbWlycm9yL2xhbmd1YWdlIjsKaW1wb3J0IHsgRWRpdG9yU3RhdGUsIFJhbmdlU2V0QnVpbGRlciwgU3RhdGVGaWVsZCB9IGZyb20gIkBjb2RlbWlycm9yL3N0YXRlIjsKaW1wb3J0IHsgRGVjb3JhdGlvbiwgV2lkZ2V0VHlwZSB9IGZyb20gIkBjb2RlbWlycm9yL3ZpZXciOwoKY2xhc3MgTGluayBleHRlbmRzIFdpZGdldFR5cGUgewogIGNvbnN0cnVjdG9yKHRleHQsIHVybCkgewogICAgc3VwZXIoKQogICAgdGhpcy50ZXh0ID0gdGV4dAogICAgdGhpcy51cmwgPSB1cmwKICB9CiAgZXEob3RoZXIpIHsgcmV0dXJuIHRoaXMudGV4dCA9PSBvdGhlci50ZXh0ICYmIHRoaXMudXJsID09IG90aGVyLnVybCB9CiAgdG9ET00oKSB7CiAgICBsZXQgbGluayA9IGRvY3VtZW50LmNyZWF0ZUVsZW1lbnQoImEiKQogICAgbGluay50ZXh0Q29udGVudCA9IHRoaXMudGV4dAogICAgbGluay5ocmVmID0gdGhpcy51cmwKICAgIHJldHVybiBsaW5rOwogIH0KfQoKbGV0IGRlY29yYXRpb25zRmllbGQgPSBTdGF0ZUZpZWxkLmRlZmluZSh7CiAgY3JlYXRlKCkgewogICAgcmV0dXJuIERlY29yYXRpb24ubm9uZTsKICB9LAogIHVwZGF0ZShfLCB0cikgewogICAgY29uc3QgYnVpbGRlciA9IG5ldyBSYW5nZVNldEJ1aWxkZXIoKTsKICAgIGxldCBjdXJzb3IgPSB0ci5zdGF0ZS5zZWxlY3Rpb24ubWFpbi5oZWFkOwogICAgc3ludGF4VHJlZSh0ci5zdGF0ZSkuaXRlcmF0ZSh7CiAgICAgIGVudGVyOiAobm9kZSkgPT4gewogICAgICAgIGlmICgoY3Vyc29yIDwgbm9kZS5mcm9tIHx8IGN1cnNvciA+IG5vZGUudG8pICYmIG5vZGUubmFtZSA9PSAiTGluayIpIHsKICAgICAgICAgIGxldCBtYXJrcyA9IG5vZGUubm9kZS5nZXRDaGlsZHJlbigiTGlua01hcmsiKTsKICAgICAgICAgIGxldCB0ZXh0ID0gbWFya3MubGVuZ3RoID49IDIgPyB0ci5zdGF0ZS5zbGljZURvYyhtYXJrc1swXS50bywgbWFya3NbMV0uZnJvbSkgOiAiIgoKICAgICAgICAgIGxldCB1cmxOb2RlID0gbm9kZS5ub2RlLmdldENoaWxkKCJVUkwiKTsKICAgICAgICAgIGxldCB1cmwgPSB1cmxOb2RlID8gdHIuc3RhdGUuc2xpY2VEb2ModXJsTm9kZS5mcm9tLCB1cmxOb2RlLnRvKSA6ICIiCiAgICAgICAgICBidWlsZGVyLmFkZChub2RlLmZyb20sIG5vZGUudG8sIERlY29yYXRpb24ucmVwbGFjZSh7CiAgICAgICAgICAgIHdpZGdldDogbmV3IExpbmsodGV4dCwgdXJsKSwKICAgICAgICAgIH0pLAogICAgICAgICAgICAgICAgICAgICApOwogICAgICAgICAgcmV0dXJuIGZhbHNlOwogICAgICAgIH0KICAgICAgICByZXR1cm4gdHJ1ZTsKICAgICAgfSwKICAgIH0pOwogICAgcmV0dXJuIGJ1aWxkZXIuZmluaXNoKCk7CiAgfSwKICBwcm92aWRlOiAoZikgPT4gRWRpdG9yVmlldy5kZWNvcmF0aW9ucy5mcm9tKGYpLAp9KTsKCmxldCB2aWV3ID0gbmV3IEVkaXRvclZpZXcoewogIGRvYzogIiMgSGVsbG8gV29ybGRcblxuW1Rlc3RdKGh0dHBzOi8vZXhhbXBsZS5jb20pXG5cblxuIiwKICBleHRlbnNpb25zOiBbZGVjb3JhdGlvbnNGaWVsZCwgbWFya2Rvd24oKV0sCiAgcGFyZW50OiBkb2N1bWVudC5ib2R5LAp9KTsKCnZpZXcuZm9jdXMoKTs=
12
+ // https://discuss.codemirror.net/t/link-widget-is-clicked-only-if-editor-is-out-of-focus/7433
13
+
14
+ // TODO(burdon): Adapt to make all markdown editable?
15
+
16
+ // TODO(burdon): Research overlays.
17
+ // https://stackoverflow.com/questions/26933344/codemirror-detect-and-create-links-inside-editor
18
+ // https://codemirror.net/5/doc/manual.html#addOverlay
19
+ // https://codemirror.net/5/demo/simplemode.html
20
+ // https://codemirror.net/5/demo/search.html (Search dialog).
21
+
22
+ class Link extends WidgetType {
23
+ constructor(private readonly _anchor: number, private readonly _text: string, private readonly _url?: string) {
24
+ super();
25
+ }
26
+
27
+ override eq(other: any) {
28
+ return this._text === other.text && this._url === other.url;
29
+ }
30
+
31
+ toDOM(view: EditorView) {
32
+ const link = document.createElement('a');
33
+ link.setAttribute('class', 'cm-link');
34
+ link.textContent = this._text;
35
+ if (this._url) {
36
+ link.setAttribute('target', '_blank');
37
+ link.href = this._url;
38
+ } else {
39
+ link.onclick = () => {
40
+ view.dispatch({
41
+ selection: {
42
+ anchor: this._anchor,
43
+ },
44
+ });
45
+ };
46
+ }
47
+
48
+ return link;
49
+ }
50
+ }
51
+
52
+ type HyperlinkDecorationConfig = {
53
+ link?: boolean;
54
+ };
55
+
56
+ /**
57
+ * Creates a state field to replace AST elements with a hyperlink widget.
58
+ * https://codemirror.net/docs/ref/#state.StateField
59
+ */
60
+ export const hyperlinkDecoration = ({ link }: HyperlinkDecorationConfig = {}) => {
61
+ return StateField.define<RangeSet<any>>({
62
+ create: (state) => update(state, link),
63
+ update: (_: RangeSet<any>, tr: Transaction) => update(tr.state, link),
64
+ provide: (field) => EditorView.decorations.from(field),
65
+ });
66
+ };
67
+
68
+ const update = (state: EditorState, link?: boolean) => {
69
+ const builder = new RangeSetBuilder();
70
+ const cursor = state.selection.main.head;
71
+ syntaxTree(state).iterate({
72
+ enter: (node) => {
73
+ // Check if cursor is inside text.
74
+ if (cursor < node.from || cursor > node.to) {
75
+ if (node.name === 'Link') {
76
+ const marks = node.node.getChildren('LinkMark');
77
+ const text = marks.length >= 2 ? state.sliceDoc(marks[0].to, marks[1].from) : '';
78
+
79
+ const urlNode = node.node.getChild('URL');
80
+ const url = urlNode ? state.sliceDoc(urlNode.from, urlNode.to) : '';
81
+
82
+ builder.add(
83
+ node.from,
84
+ node.to,
85
+ Decoration.replace({
86
+ widget: new Link(node.from, text, link ? url : undefined),
87
+ }),
88
+ );
89
+
90
+ return false;
91
+ }
92
+ }
93
+
94
+ return true;
95
+ },
96
+ });
97
+
98
+ return builder.finish();
99
+ };
@@ -15,6 +15,7 @@ export type OnHyperlinkHover = (el: Element, url: string) => void;
15
15
  * https://codemirror.net/docs/ref/#view.hoverTooltip
16
16
  * https://github.com/codemirror/view/blob/main/src/tooltip.ts
17
17
  */
18
+ // TODO(burdon): Create config object.
18
19
  export const createHyperlinkTooltip = (onHover: OnHyperlinkHover, regexp = markdownLinkRegexp) =>
19
20
  hoverTooltip((view, pos) => {
20
21
  const { from, text } = view.state.doc.lineAt(pos);
@@ -15,14 +15,20 @@ import {
15
15
 
16
16
  // Notes: Can't edit widget content (or cursor nav through them).
17
17
  // - https://discuss.codemirror.net/t/focusing-inputs-within-widgets/5178/7
18
- // Widgets intentionally always get set to contenteditable=false, or they would become part of CodeMirror’s editable content element.
18
+ // Widgets intentionally always get set to contenteditable=false,
19
+ // or they would become part of CodeMirror’s editable content element.
19
20
  // You should be able to introduce new contenteditable=true child elements inside of them.
20
21
 
21
22
  const markdownLinkRegexp = /\[([^\]]+)]\(([^)]+)\)(!?)/gi;
22
23
 
24
+ /**
25
+ * Custom link decorator.
26
+ * NOTE: The default markdown extension formats links by default.
27
+ * @deprecated
28
+ */
23
29
  const linkDecorator = () =>
24
30
  new MatchDecorator({
25
- regexp: markdownLinkRegexp, // regexp || defaultRegexp,
31
+ regexp: markdownLinkRegexp,
26
32
  decorate: (add, from, to, match, view) => {
27
33
  const [_, label, url] = match;
28
34
  const p0 = {
@@ -45,16 +51,21 @@ const linkDecorator = () =>
45
51
  end: p1.end + 1 + url.length + 2,
46
52
  };
47
53
 
48
- // TODO(burdon): Wrap with tagName?
49
- add(p0.start, p0.end, Decoration.mark({ class: 'cm-hyperlink-bracket' }));
54
+ add(
55
+ p0.start,
56
+ p0.end,
57
+ Decoration.mark({ class: 'cm-hyperlink-bracket', _attributes: { style: 'display: none' } }),
58
+ );
50
59
  add(p1.start, p1.end, Decoration.mark({ class: 'cm-hyperlink-label' }));
51
- add(p2.start, p2.end, Decoration.mark({ class: 'cm-hyperlink-bracket' }));
52
- add(p3.start, p3.end, Decoration.mark({ class: 'cm-hyperlink-url', __attributes: { style: 'display: none' } }));
60
+ add(
61
+ p2.start,
62
+ p2.end,
63
+ Decoration.mark({ class: 'cm-hyperlink-bracket', _attributes: { style: 'display: none' } }),
64
+ );
65
+ add(p3.start, p3.end, Decoration.mark({ class: 'cm-hyperlink-url', _attributes: { style: 'display: none' } }));
53
66
  },
54
67
  });
55
68
 
56
- // const anchor = (dom: HTMLAnchorElement) => dom;
57
-
58
69
  export function hyperLinkExtension() {
59
70
  return ViewPlugin.fromClass(
60
71
  class HyperLinkView {
@@ -91,7 +102,7 @@ export function hyperLinkExtension() {
91
102
  );
92
103
  }
93
104
 
94
- export const hyperLinkStyle = EditorView.baseTheme({
105
+ export const hyperlinkStyle = EditorView.baseTheme({
95
106
  '.cm-hyperlink-label': {
96
107
  cursor: 'pointer',
97
108
  textDecoration: 'underline',
@@ -105,4 +116,4 @@ export const hyperLinkStyle = EditorView.baseTheme({
105
116
  },
106
117
  });
107
118
 
108
- export const hyperlink: Extension = [hyperLinkExtension(), hyperLinkStyle];
119
+ export const hyperlinkWidget: Extension = [hyperLinkExtension(), hyperlinkStyle];
@@ -0,0 +1,7 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ export * from './hyperlinkDecoration';
6
+ export * from './hyperlinkTooltip';
7
+ export * from './hyperlinkWidget';
@@ -2,5 +2,7 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
+ export * from './basic';
6
+ export * from './change';
5
7
  export * from './hyperlink';
6
- export * from './hyperlinkTooltip';
8
+ export * from './markdown';