@dxos/react-ui-editor 0.3.9-main.dd8d283 → 0.3.9-main.de60860
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.
- package/dist/lib/browser/index.mjs +412 -56
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/types/src/automerge/automerge-plugin/index.d.ts +1 -1
- package/dist/types/src/automerge/automerge-plugin/index.d.ts.map +1 -1
- package/dist/types/src/automerge/automerge.stories.d.ts.map +1 -1
- package/dist/types/src/components/Editor/Editor.d.ts +0 -1
- package/dist/types/src/components/Editor/Editor.d.ts.map +1 -1
- package/dist/types/src/components/Editor/Editor.stories.d.ts +3 -8
- package/dist/types/src/components/Editor/Editor.stories.d.ts.map +1 -1
- package/dist/types/src/components/Markdown/Markdown.d.ts +4 -2
- package/dist/types/src/components/Markdown/Markdown.d.ts.map +1 -1
- package/dist/types/src/components/Markdown/Markdown.stories.d.ts +1 -1
- package/dist/types/src/components/Markdown/Markdown.stories.d.ts.map +1 -1
- package/dist/types/src/components/Markdown/markdownTheme.d.ts +10 -17
- package/dist/types/src/components/Markdown/markdownTheme.d.ts.map +1 -1
- package/dist/types/src/components/RichText/RichText.stories.d.ts +2 -3
- package/dist/types/src/components/RichText/RichText.stories.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/TextEditor.d.ts +39 -0
- package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -0
- package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts +18 -0
- package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts.map +1 -0
- package/dist/types/src/components/TextEditor/extensions/hyperlinkDecoration.d.ts +11 -0
- package/dist/types/src/components/TextEditor/extensions/hyperlinkDecoration.d.ts.map +1 -0
- package/dist/types/src/components/TextEditor/extensions/hyperlinkTooltip.d.ts +8 -0
- package/dist/types/src/components/TextEditor/extensions/hyperlinkTooltip.d.ts.map +1 -0
- package/dist/types/src/components/TextEditor/extensions/hyperlinkWidget.d.ts +10 -0
- package/dist/types/src/components/TextEditor/extensions/hyperlinkWidget.d.ts.map +1 -0
- package/dist/types/src/components/TextEditor/extensions/index.d.ts +4 -0
- package/dist/types/src/components/TextEditor/extensions/index.d.ts.map +1 -0
- package/dist/types/src/components/TextEditor/index.d.ts +6 -0
- package/dist/types/src/components/TextEditor/index.d.ts.map +1 -0
- package/dist/types/src/components/TextEditor/theme.d.ts +9 -0
- package/dist/types/src/components/TextEditor/theme.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +1 -0
- package/dist/types/src/components/index.d.ts.map +1 -1
- package/dist/types/src/testing/replicator.d.ts.map +1 -1
- package/package.json +21 -17
- package/src/automerge/automerge-plugin/index.ts +1 -1
- package/src/automerge/automerge.stories.tsx +1 -2
- package/src/components/Editor/Editor.stories.tsx +7 -7
- package/src/components/Editor/Editor.tsx +6 -3
- package/src/components/Markdown/Markdown.stories.tsx +39 -25
- package/src/components/Markdown/Markdown.tsx +63 -30
- package/src/components/Markdown/markdownTheme.ts +32 -17
- package/src/components/RichText/RichText.stories.tsx +37 -33
- package/src/components/TextEditor/TextEditor.stories.tsx +72 -0
- package/src/components/TextEditor/TextEditor.tsx +131 -0
- package/src/components/TextEditor/extensions/hyperlinkDecoration.ts +99 -0
- package/src/components/TextEditor/extensions/hyperlinkTooltip.tsx +61 -0
- package/src/components/TextEditor/extensions/hyperlinkWidget.tsx +119 -0
- package/src/components/TextEditor/extensions/index.ts +7 -0
- package/src/components/TextEditor/index.ts +11 -0
- package/src/components/TextEditor/theme.ts +35 -0
- package/src/components/index.ts +1 -0
- package/src/testing/replicator.ts +0 -1
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
import { languages } from '@codemirror/language-data';
|
|
16
16
|
import { lintKeymap } from '@codemirror/lint';
|
|
17
17
|
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
|
18
|
-
import { EditorState, StateField, type Text } from '@codemirror/state';
|
|
18
|
+
import { EditorState, type Extension, StateField, type Text } from '@codemirror/state';
|
|
19
19
|
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
|
20
20
|
import {
|
|
21
21
|
keymap,
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
EditorView,
|
|
31
31
|
} from '@codemirror/view';
|
|
32
32
|
import { useFocusableGroup } from '@fluentui/react-tabster';
|
|
33
|
+
// import { GFM } from '@lezer/markdown';
|
|
33
34
|
import { vim } from '@replit/codemirror-vim';
|
|
34
35
|
import React, {
|
|
35
36
|
type KeyboardEvent,
|
|
@@ -48,16 +49,18 @@ import { getColorForValue } from '@dxos/react-ui-theme';
|
|
|
48
49
|
import { YText } from '@dxos/text-model';
|
|
49
50
|
|
|
50
51
|
import { markdownTagsExtension } from './markdownTags';
|
|
51
|
-
import {
|
|
52
|
+
import { markdownHighlightStyle, markdownTheme } from './markdownTheme';
|
|
52
53
|
import { type EditorModel, type EditorSlots } from '../../model';
|
|
53
54
|
|
|
54
55
|
export const EditorModes = ['default', 'vim'] as const;
|
|
55
56
|
export type EditorMode = (typeof EditorModes)[number];
|
|
56
57
|
|
|
57
58
|
export type MarkdownEditorProps = {
|
|
58
|
-
model
|
|
59
|
+
model: EditorModel;
|
|
59
60
|
slots?: EditorSlots;
|
|
60
61
|
editorMode?: EditorMode;
|
|
62
|
+
showWidgets?: boolean;
|
|
63
|
+
extensions?: Extension[];
|
|
61
64
|
onChange?: (content: string | Text) => void;
|
|
62
65
|
};
|
|
63
66
|
|
|
@@ -68,7 +71,7 @@ export type MarkdownEditorRef = {
|
|
|
68
71
|
};
|
|
69
72
|
|
|
70
73
|
export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>(
|
|
71
|
-
({ model, slots = {},
|
|
74
|
+
({ model, slots = {}, editorMode, extensions = [], onChange }, forwardedRef) => {
|
|
72
75
|
const { id, content, provider, peer } = model ?? {};
|
|
73
76
|
const { themeMode } = useThemeContext();
|
|
74
77
|
const tabsterDOMAttribute = useFocusableGroup({ tabBehavior: 'limited' });
|
|
@@ -115,50 +118,77 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|
|
115
118
|
const state = EditorState.create({
|
|
116
119
|
doc: content?.toString(),
|
|
117
120
|
extensions: [
|
|
118
|
-
// Based on https://github.com/codemirror/dev/issues/44#issuecomment-789093799
|
|
121
|
+
// Based on https://github.com/codemirror/dev/issues/44#issuecomment-789093799
|
|
119
122
|
listenChangesExtension,
|
|
120
123
|
|
|
121
|
-
|
|
122
|
-
|
|
124
|
+
// TODO(burdon): Create custom bundle to make this class more reusable and retire simpler TextEditor component.
|
|
123
125
|
// All of https://github.com/codemirror/basic-setup minus line numbers and fold gutter.
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
history(),
|
|
127
|
-
drawSelection(),
|
|
128
|
-
dropCursor(),
|
|
129
|
-
EditorState.allowMultipleSelections.of(true),
|
|
130
|
-
indentOnInput(),
|
|
131
|
-
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
|
126
|
+
// https://codemirror.net/docs/ref/#codemirror.basicSetup
|
|
127
|
+
autocompletion(),
|
|
132
128
|
bracketMatching(),
|
|
133
129
|
closeBrackets(),
|
|
134
|
-
autocompletion(),
|
|
135
|
-
rectangularSelection(),
|
|
136
130
|
crosshairCursor(),
|
|
131
|
+
dropCursor(),
|
|
132
|
+
drawSelection(),
|
|
137
133
|
highlightActiveLine(),
|
|
134
|
+
highlightActiveLineGutter(),
|
|
138
135
|
highlightSelectionMatches(),
|
|
136
|
+
highlightSpecialChars(),
|
|
137
|
+
history(),
|
|
138
|
+
indentOnInput(),
|
|
139
139
|
placeholder(slots.editor?.placeholder ?? ''), // TODO(burdon): Needs consistent styling.
|
|
140
|
+
rectangularSelection(),
|
|
141
|
+
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
|
142
|
+
EditorState.allowMultipleSelections.of(true),
|
|
143
|
+
EditorView.lineWrapping,
|
|
144
|
+
|
|
140
145
|
keymap.of([
|
|
141
146
|
...closeBracketsKeymap,
|
|
147
|
+
...completionKeymap,
|
|
142
148
|
...defaultKeymap,
|
|
143
|
-
...searchKeymap,
|
|
144
|
-
...historyKeymap,
|
|
145
149
|
...foldKeymap,
|
|
146
|
-
...
|
|
150
|
+
...historyKeymap,
|
|
147
151
|
...lintKeymap,
|
|
152
|
+
...searchKeymap,
|
|
148
153
|
indentWithTab,
|
|
149
154
|
]),
|
|
150
|
-
|
|
151
|
-
//
|
|
152
|
-
markdown
|
|
155
|
+
|
|
156
|
+
// Main extension.
|
|
157
|
+
// https://github.com/codemirror/lang-markdown
|
|
158
|
+
markdown({
|
|
159
|
+
base: markdownLanguage,
|
|
160
|
+
codeLanguages: languages,
|
|
161
|
+
extensions: [
|
|
162
|
+
// TODO(burdon): This seems to upgrade the parser.
|
|
163
|
+
// GitHub flavored markdown bundle: Table, TaskList, Strikethrough, and Autolink.
|
|
164
|
+
// https://github.com/lezer-parser/markdown?tab=readme-ov-file#github-flavored-markdown
|
|
165
|
+
// https://github.github.com/gfm
|
|
166
|
+
// GFM,
|
|
167
|
+
|
|
168
|
+
// Custom styling.
|
|
169
|
+
markdownTagsExtension,
|
|
170
|
+
],
|
|
171
|
+
}),
|
|
172
|
+
|
|
173
|
+
// Theme.
|
|
153
174
|
EditorView.theme({ ...markdownTheme, ...slots.editor?.markdownTheme }),
|
|
154
175
|
...(themeMode === 'dark'
|
|
155
176
|
? [syntaxHighlighting(oneDarkHighlightStyle)]
|
|
156
177
|
: [syntaxHighlighting(defaultHighlightStyle)]),
|
|
178
|
+
|
|
157
179
|
// TODO(thure): All but one rule here apply to both themes; rename or refactor.
|
|
158
|
-
syntaxHighlighting(
|
|
180
|
+
syntaxHighlighting(markdownHighlightStyle),
|
|
181
|
+
|
|
182
|
+
// Settings.
|
|
183
|
+
...(editorMode === 'vim' ? [vim()] : []),
|
|
159
184
|
|
|
160
|
-
//
|
|
185
|
+
// TODO(burdon): Move to extensions.
|
|
186
|
+
// Replication and awareness (incl. remote selection).
|
|
187
|
+
// https://codemirror.net/docs/ref/#collab
|
|
161
188
|
...(content instanceof YText ? [yCollab(content, provider?.awareness)] : []),
|
|
189
|
+
|
|
190
|
+
// Custom.
|
|
191
|
+
...extensions,
|
|
162
192
|
],
|
|
163
193
|
});
|
|
164
194
|
|
|
@@ -178,15 +208,18 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|
|
178
208
|
}, [parent, content, provider?.awareness, themeMode, editorMode]);
|
|
179
209
|
|
|
180
210
|
const handleKeyUp = useCallback(
|
|
181
|
-
(
|
|
211
|
+
(event: KeyboardEvent) => {
|
|
212
|
+
const { key, altKey, shiftKey, metaKey, ctrlKey } = event;
|
|
182
213
|
switch (key) {
|
|
183
|
-
case 'Enter':
|
|
214
|
+
case 'Enter': {
|
|
184
215
|
view?.contentDOM.focus();
|
|
185
216
|
break;
|
|
217
|
+
}
|
|
186
218
|
|
|
187
|
-
case 'Escape':
|
|
219
|
+
case 'Escape': {
|
|
188
220
|
editorMode === 'vim' && (altKey || shiftKey || metaKey || ctrlKey) && parent?.focus();
|
|
189
221
|
break;
|
|
222
|
+
}
|
|
190
223
|
}
|
|
191
224
|
},
|
|
192
225
|
[view, editorMode],
|
|
@@ -195,11 +228,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|
|
195
228
|
return (
|
|
196
229
|
<div
|
|
197
230
|
tabIndex={0}
|
|
231
|
+
ref={setParent}
|
|
198
232
|
key={id}
|
|
199
233
|
{...slots.root}
|
|
200
|
-
onKeyUp={handleKeyUp}
|
|
201
234
|
{...(editorMode !== 'vim' ? tabsterDOMAttribute : {})}
|
|
202
|
-
|
|
235
|
+
onKeyUp={handleKeyUp}
|
|
203
236
|
/>
|
|
204
237
|
);
|
|
205
238
|
},
|
|
@@ -32,6 +32,10 @@ export const cursor = '#ffffff';
|
|
|
32
32
|
|
|
33
33
|
const monospace = get(tokens, 'fontFamily.mono', ['monospace']).join(',');
|
|
34
34
|
|
|
35
|
+
// TODO(burdon): Subtle difference in layout for documents and stacks.
|
|
36
|
+
// TODO(burdon): On first character, height of editor changes by a few pixels.
|
|
37
|
+
// TODO(burdon): Is the '&' prefix required? If so, document.
|
|
38
|
+
|
|
35
39
|
export const markdownTheme = {
|
|
36
40
|
// TODO(thure): consider whether these commented-out rules from one-dark-theme should be integrated.
|
|
37
41
|
// '&': {
|
|
@@ -71,24 +75,28 @@ export const markdownTheme = {
|
|
|
71
75
|
// color: '#ddd'
|
|
72
76
|
// },
|
|
73
77
|
|
|
74
|
-
'.
|
|
78
|
+
'.cm-tooltip': {
|
|
79
|
+
backgroundColor: 'transparent',
|
|
75
80
|
border: 'none',
|
|
76
|
-
backgroundColor: tooltipBackground,
|
|
77
|
-
},
|
|
78
|
-
'.dark & .cm-tooltip .cm-tooltip-arrow:before': {
|
|
79
|
-
borderTopColor: 'transparent',
|
|
80
|
-
borderBottomColor: 'transparent',
|
|
81
|
-
},
|
|
82
|
-
'.dark & .cm-tooltip .cm-tooltip-arrow:after': {
|
|
83
|
-
borderTopColor: tooltipBackground,
|
|
84
|
-
borderBottomColor: tooltipBackground,
|
|
85
|
-
},
|
|
86
|
-
'.dark & .cm-tooltip-autocomplete': {
|
|
87
|
-
'& > ul > li[aria-selected]': {
|
|
88
|
-
backgroundColor: highlightBackground,
|
|
89
|
-
color: ivory,
|
|
90
|
-
},
|
|
91
81
|
},
|
|
82
|
+
// '.dark & .cm-tooltip': {
|
|
83
|
+
// border: 'none',
|
|
84
|
+
// backgroundColor: tooltipBackground,
|
|
85
|
+
// },
|
|
86
|
+
// '.dark & .cm-tooltip .cm-tooltip-arrow:before': {
|
|
87
|
+
// borderTopColor: 'transparent',
|
|
88
|
+
// borderBottomColor: 'transparent',
|
|
89
|
+
// },
|
|
90
|
+
// '.dark & .cm-tooltip .cm-tooltip-arrow:after': {
|
|
91
|
+
// borderTopColor: tooltipBackground,
|
|
92
|
+
// borderBottomColor: tooltipBackground,
|
|
93
|
+
// },
|
|
94
|
+
// '.dark & .cm-tooltip-autocomplete': {
|
|
95
|
+
// '& > ul > li[aria-selected]': {
|
|
96
|
+
// backgroundColor: highlightBackground,
|
|
97
|
+
// color: ivory,
|
|
98
|
+
// },
|
|
99
|
+
// },
|
|
92
100
|
'&.cm-focused': {
|
|
93
101
|
outline: 'none',
|
|
94
102
|
},
|
|
@@ -160,6 +168,13 @@ export const markdownTheme = {
|
|
|
160
168
|
'& .cm-yLineSelection': {
|
|
161
169
|
margin: '0',
|
|
162
170
|
},
|
|
171
|
+
'.cm-link': {
|
|
172
|
+
color: 'rgb(20 89 208)',
|
|
173
|
+
textDecorationLine: 'underline',
|
|
174
|
+
textDecorationThickness: '1px',
|
|
175
|
+
textUnderlineOffset: '2px',
|
|
176
|
+
borderRadius: '.125rem',
|
|
177
|
+
},
|
|
163
178
|
...Object.keys(get(tokens, 'extend.fontSize', {})).reduce((acc: Record<string, any>, fontSize) => {
|
|
164
179
|
const height = get(tokens, ['extend', 'fontSize', fontSize, 1, 'lineHeight']);
|
|
165
180
|
// TODO(thure): This appears to be the best or only way to set selection caret heights, but it's far more verbose than it needs to be.
|
|
@@ -170,7 +185,7 @@ export const markdownTheme = {
|
|
|
170
185
|
}, {}),
|
|
171
186
|
};
|
|
172
187
|
|
|
173
|
-
export const
|
|
188
|
+
export const markdownHighlightStyle = HighlightStyle.define(
|
|
174
189
|
[
|
|
175
190
|
{
|
|
176
191
|
tag: [
|
|
@@ -8,15 +8,17 @@ import React, { useState } from 'react';
|
|
|
8
8
|
|
|
9
9
|
import { PublicKey } from '@dxos/react-client';
|
|
10
10
|
import { TextKind } from '@dxos/react-client/echo';
|
|
11
|
-
import {
|
|
11
|
+
import { ClientRepeater, textGenerator, useDataGenerator } from '@dxos/react-client/testing';
|
|
12
12
|
import { useId } from '@dxos/react-ui';
|
|
13
13
|
import { mx } from '@dxos/react-ui-theme';
|
|
14
|
+
import { withTheme } from '@dxos/storybook-utils';
|
|
14
15
|
|
|
15
16
|
import { RichTextEditor, type RichTextEditorProps } from './RichText';
|
|
16
17
|
import { Replicator, useYjsModel } from '../../testing';
|
|
17
18
|
|
|
18
19
|
export default {
|
|
19
20
|
component: RichTextEditor,
|
|
21
|
+
decorators: [withTheme],
|
|
20
22
|
};
|
|
21
23
|
|
|
22
24
|
export const Default = {
|
|
@@ -29,40 +31,42 @@ export const Default = {
|
|
|
29
31
|
};
|
|
30
32
|
|
|
31
33
|
const replicator = new Replicator(TextKind.RICH);
|
|
32
|
-
export const WithYjs = {
|
|
33
|
-
render: (args: Omit<RichTextEditorProps, 'model'>) => {
|
|
34
|
-
const [generate, setGenerate] = useState(false);
|
|
35
|
-
const generateId = useId('generate');
|
|
36
34
|
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
const Story = (args: Omit<RichTextEditorProps, 'model'>) => {
|
|
36
|
+
const [generate, setGenerate] = useState(false);
|
|
37
|
+
const generateId = useId('generate');
|
|
39
38
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
options: { text: typeof model?.content !== 'string' ? model?.content : undefined },
|
|
43
|
-
});
|
|
39
|
+
const [id] = useState(PublicKey.random().toHex());
|
|
40
|
+
const model = useYjsModel({ id, replicator });
|
|
44
41
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
42
|
+
useDataGenerator({
|
|
43
|
+
generator: generate ? textGenerator : undefined,
|
|
44
|
+
options: { text: typeof model?.content !== 'string' ? model?.content : undefined },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<main className='flex-1 min-w-0 p-4'>
|
|
49
|
+
<div id={generateId} className='flex'>
|
|
50
|
+
<input type='checkbox' onChange={(event) => setGenerate(event.target.checked)} />
|
|
51
|
+
Generate Data
|
|
52
|
+
</div>
|
|
53
|
+
<RichTextEditor
|
|
54
|
+
{...args}
|
|
55
|
+
model={model}
|
|
56
|
+
slots={{
|
|
57
|
+
editor: {
|
|
58
|
+
className: mx(
|
|
59
|
+
'z-0 rounded bg-white text-neutral-900 w-full p-4 dark:bg-neutral-850 dark:text-white min-bs-[12em]',
|
|
60
|
+
args.slots?.editor?.className,
|
|
61
|
+
),
|
|
62
|
+
},
|
|
63
|
+
}}
|
|
64
|
+
/>
|
|
65
|
+
</main>
|
|
66
|
+
);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const WithYjs = {
|
|
66
70
|
// TODO(wittjosiah): Decorator for doing this without clients being initialized?
|
|
67
|
-
|
|
71
|
+
render: () => <ClientRepeater count={2} Component={Story} />,
|
|
68
72
|
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2023 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import '@dxosTheme';
|
|
6
|
+
import { markdown } from '@codemirror/lang-markdown';
|
|
7
|
+
import { ArrowCircleUp } from '@phosphor-icons/react';
|
|
8
|
+
import React, { StrictMode, useState } from 'react';
|
|
9
|
+
import { createRoot } from 'react-dom/client';
|
|
10
|
+
|
|
11
|
+
import { TextObject } from '@dxos/echo-schema';
|
|
12
|
+
import { fixedInsetFlexLayout, getSize, groupSurface, inputSurface, mx } from '@dxos/react-ui-theme';
|
|
13
|
+
import { withTheme } from '@dxos/storybook-utils';
|
|
14
|
+
|
|
15
|
+
import { TextEditor } from './TextEditor';
|
|
16
|
+
import { createHyperlinkTooltip, hyperlinkDecoration } from './extensions';
|
|
17
|
+
import { useTextModel } from '../../model';
|
|
18
|
+
|
|
19
|
+
const text = [
|
|
20
|
+
'',
|
|
21
|
+
'',
|
|
22
|
+
'This is all about [DXOS](https://dxos.org); read more [here](https://docs.dxos.org/guide/getting-started.html).',
|
|
23
|
+
'',
|
|
24
|
+
'',
|
|
25
|
+
].join('\n');
|
|
26
|
+
|
|
27
|
+
const hyperLinkTooltip = () =>
|
|
28
|
+
createHyperlinkTooltip((el, url) => {
|
|
29
|
+
const web = new URL(url);
|
|
30
|
+
createRoot(el).render(
|
|
31
|
+
<StrictMode>
|
|
32
|
+
<div className='flex gap-1 items-center'>
|
|
33
|
+
<ArrowCircleUp className={mx(getSize(6), 'text-blue-500')} />
|
|
34
|
+
<p className='pr-1'>{web.origin}</p>
|
|
35
|
+
</div>
|
|
36
|
+
</StrictMode>,
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const Story = () => {
|
|
41
|
+
const [item] = useState({ text: new TextObject(text) });
|
|
42
|
+
const model = useTextModel({ text: item.text });
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className={mx(fixedInsetFlexLayout, groupSurface)}>
|
|
46
|
+
<div className='flex justify-center p-8'>
|
|
47
|
+
<div className='w-[800px]'>
|
|
48
|
+
<TextEditor
|
|
49
|
+
model={model}
|
|
50
|
+
extensions={[
|
|
51
|
+
markdown(),
|
|
52
|
+
hyperlinkDecoration({ link: false }),
|
|
53
|
+
hyperLinkTooltip(),
|
|
54
|
+
// EditorView.domEventHandlers({
|
|
55
|
+
// mousedown: (e, view) => {},
|
|
56
|
+
// }),
|
|
57
|
+
]}
|
|
58
|
+
slots={{ root: { className: mx(inputSurface, 'p-2') } }}
|
|
59
|
+
/>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export default {
|
|
67
|
+
component: TextEditor,
|
|
68
|
+
decorators: [withTheme],
|
|
69
|
+
render: Story,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const Default = {};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2023 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { closeBrackets } from '@codemirror/autocomplete';
|
|
6
|
+
import { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
|
7
|
+
import { EditorState, type Extension } from '@codemirror/state';
|
|
8
|
+
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
|
9
|
+
import { EditorView, placeholder } from '@codemirror/view';
|
|
10
|
+
import React, {
|
|
11
|
+
type KeyboardEvent,
|
|
12
|
+
forwardRef,
|
|
13
|
+
useEffect,
|
|
14
|
+
useImperativeHandle,
|
|
15
|
+
useState,
|
|
16
|
+
useCallback,
|
|
17
|
+
type HTMLAttributes,
|
|
18
|
+
} from 'react';
|
|
19
|
+
import { type StyleSpec } from 'style-mod';
|
|
20
|
+
import { yCollab } from 'y-codemirror.next';
|
|
21
|
+
|
|
22
|
+
import { useThemeContext } from '@dxos/react-ui';
|
|
23
|
+
import { YText } from '@dxos/text-model';
|
|
24
|
+
|
|
25
|
+
import { defaultStyles } from './theme';
|
|
26
|
+
import { type EditorModel, type EditorSlots } from '../../model';
|
|
27
|
+
|
|
28
|
+
export type CursorInfo = {
|
|
29
|
+
from: number;
|
|
30
|
+
to: number;
|
|
31
|
+
line: number;
|
|
32
|
+
lines: number;
|
|
33
|
+
after?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type TextEditorRef = {
|
|
37
|
+
editor: HTMLDivElement | null;
|
|
38
|
+
state?: EditorState;
|
|
39
|
+
view?: EditorView;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type TextEditorProps = {
|
|
43
|
+
model?: EditorModel;
|
|
44
|
+
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'>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Simple text editor.
|
|
54
|
+
*/
|
|
55
|
+
export const TextEditor = forwardRef<TextEditorRef, TextEditorProps>(
|
|
56
|
+
({ model, extensions = [], theme = defaultStyles, slots = {}, onKeyDown, ...props }, forwardedRef) => {
|
|
57
|
+
const { id, content } = model ?? {};
|
|
58
|
+
const { themeMode } = useThemeContext();
|
|
59
|
+
|
|
60
|
+
const [parent, setParent] = useState<HTMLDivElement | null>(null);
|
|
61
|
+
const [state, setState] = useState<EditorState>();
|
|
62
|
+
const [view, setView] = useState<EditorView>();
|
|
63
|
+
|
|
64
|
+
// TODO(burdon): The ref may be instantiated before the view is created.
|
|
65
|
+
useImperativeHandle(
|
|
66
|
+
forwardedRef,
|
|
67
|
+
() => {
|
|
68
|
+
return {
|
|
69
|
+
editor: parent,
|
|
70
|
+
state,
|
|
71
|
+
view,
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
[view],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!parent) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
view?.destroy();
|
|
83
|
+
|
|
84
|
+
const state = EditorState.create({
|
|
85
|
+
doc: content?.toString(),
|
|
86
|
+
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)]),
|
|
97
|
+
|
|
98
|
+
// Replication.
|
|
99
|
+
// TODO(burdon): Move to default extensions.
|
|
100
|
+
...(content instanceof YText ? [yCollab(content, undefined)] : []),
|
|
101
|
+
|
|
102
|
+
// Custom.
|
|
103
|
+
...extensions,
|
|
104
|
+
],
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
setState(state);
|
|
108
|
+
setView(new EditorView({ state, parent }));
|
|
109
|
+
|
|
110
|
+
return () => {
|
|
111
|
+
view?.destroy();
|
|
112
|
+
setView(undefined);
|
|
113
|
+
setState(undefined);
|
|
114
|
+
};
|
|
115
|
+
}, [parent, content, themeMode]);
|
|
116
|
+
|
|
117
|
+
const handleKeyDown = useCallback(
|
|
118
|
+
(event: KeyboardEvent) => {
|
|
119
|
+
if (view) {
|
|
120
|
+
const { head, from, to } = view.state.selection.ranges[0];
|
|
121
|
+
const { number } = view.state.doc.lineAt(head);
|
|
122
|
+
const after = view.state.sliceDoc(from);
|
|
123
|
+
onKeyDown?.(event, { from, to, line: number, lines: view.state.doc.lines, after });
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
[view],
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
return <div key={id} ref={setParent} {...slots.root} onKeyDown={handleKeyDown} {...props} />;
|
|
130
|
+
},
|
|
131
|
+
);
|
|
@@ -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
|
+
};
|