@dxos/react-ui-editor 0.4.4 → 0.4.5-main.17763ce
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 +421 -540
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts +5 -5
- package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/hooks.stories.d.ts.map +1 -1
- package/dist/types/src/components/Toolbar/Toolbar.d.ts.map +1 -1
- package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts.map +1 -1
- package/dist/types/src/extensions/cursor-line-margin.d.ts +8 -0
- package/dist/types/src/extensions/cursor-line-margin.d.ts.map +1 -0
- package/dist/types/src/extensions/index.d.ts +1 -0
- package/dist/types/src/extensions/index.d.ts.map +1 -1
- package/dist/types/src/extensions/markdown/decorate.d.ts +5 -0
- package/dist/types/src/extensions/markdown/decorate.d.ts.map +1 -0
- package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -1
- package/dist/types/src/extensions/markdown/index.d.ts +1 -4
- package/dist/types/src/extensions/markdown/index.d.ts.map +1 -1
- package/dist/types/src/extensions/markdown/link.d.ts +1 -11
- package/dist/types/src/extensions/markdown/link.d.ts.map +1 -1
- package/dist/types/src/styles/layout.d.ts +2 -2
- package/dist/types/src/styles/layout.d.ts.map +1 -1
- package/package.json +31 -28
- package/src/components/TextEditor/MarkdownEditor.stories.tsx +29 -37
- package/src/components/TextEditor/hooks.stories.tsx +5 -17
- package/src/components/Toolbar/Toolbar.stories.tsx +4 -7
- package/src/components/Toolbar/Toolbar.tsx +0 -1
- package/src/extensions/cursor-line-margin.ts +92 -0
- package/src/extensions/index.ts +1 -0
- package/src/extensions/markdown/decorate.ts +301 -0
- package/src/extensions/markdown/formatting.test.ts +17 -0
- package/src/extensions/markdown/formatting.ts +67 -76
- package/src/extensions/markdown/index.ts +1 -4
- package/src/extensions/markdown/link.ts +23 -185
- package/src/styles/layout.ts +3 -2
- package/dist/types/src/extensions/markdown/code.d.ts +0 -2
- package/dist/types/src/extensions/markdown/code.d.ts.map +0 -1
- package/dist/types/src/extensions/markdown/heading.d.ts +0 -6
- package/dist/types/src/extensions/markdown/heading.d.ts.map +0 -1
- package/dist/types/src/extensions/markdown/hr.d.ts +0 -2
- package/dist/types/src/extensions/markdown/hr.d.ts.map +0 -1
- package/dist/types/src/extensions/markdown/tasklist.d.ts +0 -3
- package/dist/types/src/extensions/markdown/tasklist.d.ts.map +0 -1
- package/src/extensions/markdown/code.ts +0 -162
- package/src/extensions/markdown/heading.ts +0 -59
- package/src/extensions/markdown/hr.ts +0 -71
- package/src/extensions/markdown/tasklist.ts +0 -110
|
@@ -19,19 +19,15 @@ import { withTheme } from '@dxos/storybook-utils';
|
|
|
19
19
|
import { MarkdownEditor, type TextEditorProps } from './TextEditor';
|
|
20
20
|
import {
|
|
21
21
|
type CommentsOptions,
|
|
22
|
-
type LinkOptions,
|
|
23
22
|
autocomplete,
|
|
24
23
|
blast,
|
|
25
|
-
|
|
24
|
+
decorateMarkdown,
|
|
26
25
|
comments,
|
|
27
26
|
defaultOptions,
|
|
28
|
-
heading,
|
|
29
|
-
hr,
|
|
30
27
|
image,
|
|
31
|
-
|
|
28
|
+
linkTooltip,
|
|
32
29
|
mention,
|
|
33
30
|
table,
|
|
34
|
-
tasklist,
|
|
35
31
|
typewriter,
|
|
36
32
|
useComments,
|
|
37
33
|
formatting,
|
|
@@ -47,9 +43,11 @@ const str = (...lines: string[]) => lines.join('\n');
|
|
|
47
43
|
|
|
48
44
|
const num = () => faker.number.int({ min: 0, max: 9999 }).toLocaleString();
|
|
49
45
|
|
|
50
|
-
|
|
46
|
+
const img = '';
|
|
47
|
+
|
|
51
48
|
const text = {
|
|
52
49
|
tasks: str(
|
|
50
|
+
//
|
|
53
51
|
'## Tasks',
|
|
54
52
|
'',
|
|
55
53
|
'- [x] decorator',
|
|
@@ -61,6 +59,7 @@ const text = {
|
|
|
61
59
|
),
|
|
62
60
|
|
|
63
61
|
list: str(
|
|
62
|
+
//
|
|
64
63
|
'## List',
|
|
65
64
|
'',
|
|
66
65
|
'- new york',
|
|
@@ -70,12 +69,13 @@ const text = {
|
|
|
70
69
|
),
|
|
71
70
|
|
|
72
71
|
numbered: str(
|
|
72
|
+
//
|
|
73
73
|
'## Numbered',
|
|
74
74
|
'',
|
|
75
75
|
'1. one',
|
|
76
76
|
'2. two',
|
|
77
77
|
'3. three',
|
|
78
|
-
''
|
|
78
|
+
'',
|
|
79
79
|
),
|
|
80
80
|
|
|
81
81
|
code: str(
|
|
@@ -115,7 +115,7 @@ const text = {
|
|
|
115
115
|
'',
|
|
116
116
|
),
|
|
117
117
|
|
|
118
|
-
image: str('# Image', '',
|
|
118
|
+
image: str('# Image', '', img),
|
|
119
119
|
|
|
120
120
|
headings: str(
|
|
121
121
|
...[1, 2, 3, 4, 5, 6].map((level) => ['#'.repeat(level) + ` Heading ${level}`, faker.lorem.sentences(), '']).flat(),
|
|
@@ -123,7 +123,7 @@ const text = {
|
|
|
123
123
|
|
|
124
124
|
paragraphs: str(...faker.helpers.multiple(() => [faker.lorem.paragraph(), ''], { count: 3 }).flat()),
|
|
125
125
|
|
|
126
|
-
footer: str('', '', '', '', '')
|
|
126
|
+
footer: str('', '', '', '', ''),
|
|
127
127
|
};
|
|
128
128
|
|
|
129
129
|
const document = str(
|
|
@@ -173,7 +173,7 @@ const names = ['adam', 'alice', 'alison', 'bob', 'carol', 'charlie', 'sayuri', '
|
|
|
173
173
|
const hover =
|
|
174
174
|
'rounded-sm text-base text-primary-600 hover:text-primary-500 dark:text-primary-300 hover:dark:text-primary-200';
|
|
175
175
|
|
|
176
|
-
const
|
|
176
|
+
const renderLinkTooltip = (el: Element, url: string) => {
|
|
177
177
|
const web = new URL(url);
|
|
178
178
|
createRoot(el).render(
|
|
179
179
|
<StrictMode>
|
|
@@ -206,7 +206,7 @@ const onCommentsHover: CommentsOptions['onHover'] = (el, shortcut) => {
|
|
|
206
206
|
);
|
|
207
207
|
};
|
|
208
208
|
|
|
209
|
-
const
|
|
209
|
+
const renderLinkButton = (el: Element, url: string) => {
|
|
210
210
|
createRoot(el).render(
|
|
211
211
|
<StrictMode>
|
|
212
212
|
<a href={url} target='_blank' rel='noreferrer' className={hover}>
|
|
@@ -257,14 +257,11 @@ const defaults = [
|
|
|
257
257
|
autocomplete({
|
|
258
258
|
onSearch: (text) => links.filter(({ label }) => label.toLowerCase().includes(text.toLowerCase())),
|
|
259
259
|
}),
|
|
260
|
-
|
|
260
|
+
decorateMarkdown({ renderLinkButton }),
|
|
261
261
|
formatting(),
|
|
262
|
-
heading(),
|
|
263
|
-
hr(),
|
|
264
262
|
image(),
|
|
265
|
-
|
|
263
|
+
linkTooltip(renderLinkTooltip),
|
|
266
264
|
table(),
|
|
267
|
-
tasklist(),
|
|
268
265
|
];
|
|
269
266
|
|
|
270
267
|
export const Default = {
|
|
@@ -281,6 +278,11 @@ export const NoExtensions = {
|
|
|
281
278
|
|
|
282
279
|
const large = faker.helpers.multiple(() => faker.lorem.paragraph({ min: 8, max: 16 }), { count: 20 }).join('\n\n');
|
|
283
280
|
|
|
281
|
+
const largeWithImages = faker.helpers
|
|
282
|
+
.multiple(() => [faker.lorem.paragraph({ min: 12, max: 16 }), img], { count: 20 })
|
|
283
|
+
.flatMap((x) => x)
|
|
284
|
+
.join('\n\n');
|
|
285
|
+
|
|
284
286
|
export const Empty = {
|
|
285
287
|
render: () => <Story />,
|
|
286
288
|
};
|
|
@@ -289,26 +291,25 @@ export const Scrolling = {
|
|
|
289
291
|
render: () => <Story text={str('# Large Document', '', large)} extensions={[]} />,
|
|
290
292
|
};
|
|
291
293
|
|
|
292
|
-
export const
|
|
293
|
-
render: () => (
|
|
294
|
-
<Story
|
|
295
|
-
text={str(text.links, text.footer)}
|
|
296
|
-
extensions={[link({ onHover: onHoverLinkTooltip, onRender: onRenderLink })]}
|
|
297
|
-
/>
|
|
298
|
-
),
|
|
294
|
+
export const ScrollingWithImages = {
|
|
295
|
+
render: () => <Story text={str('# Large Document', '', largeWithImages)} extensions={[image()]} />,
|
|
299
296
|
};
|
|
300
297
|
|
|
301
|
-
export const
|
|
302
|
-
render: () => <Story text={str(text.
|
|
298
|
+
export const Links = {
|
|
299
|
+
render: () => <Story text={str(text.links, text.footer)} extensions={[linkTooltip(renderLinkTooltip)]} />,
|
|
303
300
|
};
|
|
304
301
|
|
|
305
302
|
export const Image = {
|
|
306
303
|
render: () => <Story text={str(text.image, text.footer)} extensions={[image()]} />,
|
|
307
304
|
};
|
|
308
305
|
|
|
306
|
+
export const Code = {
|
|
307
|
+
render: () => <Story text={str(text.code, text.footer)} extensions={[decorateMarkdown()]} />,
|
|
308
|
+
};
|
|
309
|
+
|
|
309
310
|
export const Lists = {
|
|
310
311
|
render: () => (
|
|
311
|
-
<Story text={str(text.tasks, '', text.list, '', text.numbered, text.footer)} extensions={[
|
|
312
|
+
<Story text={str(text.tasks, '', text.list, '', text.numbered, text.footer)} extensions={[decorateMarkdown()]} />
|
|
312
313
|
),
|
|
313
314
|
};
|
|
314
315
|
|
|
@@ -330,7 +331,7 @@ export const Autocomplete = {
|
|
|
330
331
|
<Story
|
|
331
332
|
text={str('# Autocomplete', '', 'Press Ctrl-Space...', text.footer)}
|
|
332
333
|
extensions={[
|
|
333
|
-
|
|
334
|
+
decorateMarkdown({ renderLinkButton }),
|
|
334
335
|
autocomplete({
|
|
335
336
|
onSearch: (text) => links.filter(({ label }) => label.toLowerCase().includes(text.toLowerCase())),
|
|
336
337
|
}),
|
|
@@ -388,15 +389,6 @@ export const Comments = {
|
|
|
388
389
|
},
|
|
389
390
|
};
|
|
390
391
|
|
|
391
|
-
export const HorizontalRule = {
|
|
392
|
-
render: () => (
|
|
393
|
-
<Story
|
|
394
|
-
text={str('# Horizontal Rule', '', text.paragraphs, '---', text.paragraphs, '---', text.paragraphs)}
|
|
395
|
-
extensions={[hr()]}
|
|
396
|
-
/>
|
|
397
|
-
),
|
|
398
|
-
};
|
|
399
|
-
|
|
400
392
|
export const Vim = {
|
|
401
393
|
render: () => (
|
|
402
394
|
<Story
|
|
@@ -14,19 +14,15 @@ import { TextEditor } from './TextEditor';
|
|
|
14
14
|
import {
|
|
15
15
|
type EditorMode,
|
|
16
16
|
EditorModes,
|
|
17
|
-
|
|
17
|
+
decorateMarkdown,
|
|
18
18
|
createMarkdownExtensions,
|
|
19
19
|
formatting,
|
|
20
|
-
heading,
|
|
21
|
-
hr,
|
|
22
20
|
image,
|
|
23
|
-
link,
|
|
24
21
|
table,
|
|
25
|
-
tasklist,
|
|
26
22
|
useFormattingState,
|
|
27
23
|
} from '../../extensions';
|
|
28
24
|
import { createDataExtensions, createThemeExtensions, useActionHandler, useTextEditor } from '../../hooks';
|
|
29
|
-
import { editorFillLayoutEditor,
|
|
25
|
+
import { editorFillLayoutEditor, editorWithToolbarLayout } from '../../styles';
|
|
30
26
|
import { markdownTheme } from '../../themes';
|
|
31
27
|
import translations from '../../translations';
|
|
32
28
|
import { Toolbar } from '../Toolbar';
|
|
@@ -56,20 +52,16 @@ const Story = ({ autoFocus, placeholder, doc, readonly }: StoryProps) => {
|
|
|
56
52
|
themeMode,
|
|
57
53
|
theme: markdownTheme,
|
|
58
54
|
slots: {
|
|
59
|
-
|
|
55
|
+
content: { className: '!p-4' },
|
|
60
56
|
},
|
|
61
57
|
}),
|
|
62
58
|
// TODO(burdon): Move lineWrapping.
|
|
63
59
|
createMarkdownExtensions({ placeholder, lineWrapping: true }),
|
|
64
60
|
// TODO(burdon): Move into markdown bundle (with React callbacks).
|
|
65
|
-
|
|
61
|
+
decorateMarkdown(),
|
|
66
62
|
formatting(),
|
|
67
|
-
heading(),
|
|
68
|
-
hr(),
|
|
69
63
|
image(),
|
|
70
|
-
link(),
|
|
71
64
|
table(),
|
|
72
|
-
tasklist(),
|
|
73
65
|
trackFormatting,
|
|
74
66
|
],
|
|
75
67
|
[editorMode, themeMode, placeholder, trackFormatting, readonly],
|
|
@@ -87,11 +79,7 @@ const Story = ({ autoFocus, placeholder, doc, readonly }: StoryProps) => {
|
|
|
87
79
|
<EditorModeToolbar editorMode={editorMode} setEditorMode={setEditorMode} />
|
|
88
80
|
</Toolbar.Root>
|
|
89
81
|
<div role='none' className='overflow-y-auto'>
|
|
90
|
-
<div
|
|
91
|
-
role='textbox'
|
|
92
|
-
className={mx(textBlockWidth, attentionSurface, editorFillLayoutRoot, 'p-4')}
|
|
93
|
-
ref={parentRef}
|
|
94
|
-
/>
|
|
82
|
+
<div role='textbox' className={mx(textBlockWidth, attentionSurface, editorFillLayoutEditor)} ref={parentRef} />
|
|
95
83
|
</div>
|
|
96
84
|
</div>
|
|
97
85
|
);
|
|
@@ -14,13 +14,11 @@ import { withTheme } from '@dxos/storybook-utils';
|
|
|
14
14
|
|
|
15
15
|
import { Toolbar } from './Toolbar';
|
|
16
16
|
import {
|
|
17
|
-
|
|
17
|
+
decorateMarkdown,
|
|
18
18
|
comments,
|
|
19
19
|
formatting,
|
|
20
|
-
heading,
|
|
21
20
|
image,
|
|
22
21
|
table,
|
|
23
|
-
tasklist,
|
|
24
22
|
useComments,
|
|
25
23
|
useFormattingState,
|
|
26
24
|
} from '../../extensions';
|
|
@@ -39,7 +37,7 @@ const Story: FC<{ id?: string; content: string }> = ({ id = 'test', content }) =
|
|
|
39
37
|
const [formattingState, formattingObserver] = useFormattingState();
|
|
40
38
|
const extensions = useMemo(
|
|
41
39
|
() => [
|
|
42
|
-
|
|
40
|
+
decorateMarkdown(),
|
|
43
41
|
comments({
|
|
44
42
|
onCreate: ({ cursor }) => {
|
|
45
43
|
const id = PublicKey.random().toHex();
|
|
@@ -48,10 +46,8 @@ const Story: FC<{ id?: string; content: string }> = ({ id = 'test', content }) =
|
|
|
48
46
|
},
|
|
49
47
|
}),
|
|
50
48
|
formatting(),
|
|
51
|
-
heading(),
|
|
52
49
|
image(),
|
|
53
50
|
table(),
|
|
54
|
-
tasklist(),
|
|
55
51
|
formattingObserver,
|
|
56
52
|
],
|
|
57
53
|
[],
|
|
@@ -76,8 +72,9 @@ const Story: FC<{ id?: string; content: string }> = ({ id = 'test', content }) =
|
|
|
76
72
|
model={model}
|
|
77
73
|
extensions={extensions}
|
|
78
74
|
slots={{
|
|
79
|
-
root: { className: mx(textBlockWidth, editorFillLayoutRoot
|
|
75
|
+
root: { className: mx(textBlockWidth, editorFillLayoutRoot) },
|
|
80
76
|
editor: { className: editorFillLayoutEditor },
|
|
77
|
+
content: { className: '!pli-2' },
|
|
81
78
|
}}
|
|
82
79
|
/>
|
|
83
80
|
</div>
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
// Based upon @acheronfail’s implementation, fetched 16 Feb 2024
|
|
6
|
+
// https://discuss.codemirror.net/t/cursorscrollmargin-for-v6/7448/5
|
|
7
|
+
|
|
8
|
+
import { type EditorState, type Extension, Facet, Transaction } from '@codemirror/state';
|
|
9
|
+
import { EditorView } from '@codemirror/view';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Number of lines above/below the cursor to keep visible in the viewport.
|
|
13
|
+
* Defaults to 3.
|
|
14
|
+
*/
|
|
15
|
+
export const cursorLineMarginFacet = Facet.define<number, number>({
|
|
16
|
+
combine: (input) => input[0] ?? 3,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const annotation = 'cursorLineMargin';
|
|
20
|
+
const lineAtPos = (s: EditorState, pos: number) => s.doc.lineAt(pos).number;
|
|
21
|
+
// seems to the best approximation of CM5's `cursorScrollMargin`
|
|
22
|
+
// https://discuss.codemirror.net/t/cursorscrollmargin-for-v6/7448
|
|
23
|
+
export const cursorLineMargin: Extension = EditorView.updateListener.of(
|
|
24
|
+
({ transactions, state, selectionSet, startState, view }) => {
|
|
25
|
+
// make sure we don't trigger an infinite loop and ignore our own changes
|
|
26
|
+
if (transactions.length < 1 || transactions.some((tr) => tr.isUserEvent(annotation))) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const s = state;
|
|
31
|
+
const { main } = s.selection;
|
|
32
|
+
const cursorLineMargin = s.facet(cursorLineMarginFacet);
|
|
33
|
+
|
|
34
|
+
// editor rect
|
|
35
|
+
const rect = view.dom.getBoundingClientRect();
|
|
36
|
+
// top and bottom pixel positions of the visible region of the editor
|
|
37
|
+
const viewportTop = rect.top - view.documentTop;
|
|
38
|
+
const viewportBottom = rect.bottom - view.documentTop;
|
|
39
|
+
|
|
40
|
+
// line with main selection
|
|
41
|
+
const mainLine = lineAtPos(s, main.head);
|
|
42
|
+
// top most visible line
|
|
43
|
+
const _visTopLine = lineAtPos(s, view.lineBlockAtHeight(viewportTop).from);
|
|
44
|
+
|
|
45
|
+
// bottom most visible line
|
|
46
|
+
// NOTE: calculating this is slightly more complex - if the editor is
|
|
47
|
+
// larger than all the lines in it, and the `scrollPastEnd()` extension is
|
|
48
|
+
// enabled, then we need to calculate where the bottom most visible line
|
|
49
|
+
// should be if it extended all the way to the bottom of the editor
|
|
50
|
+
const botLineBlk = view.lineBlockAtHeight(viewportBottom);
|
|
51
|
+
const visBotLineDoc = lineAtPos(s, Math.min(botLineBlk.to, s.doc.length));
|
|
52
|
+
const visBotLine =
|
|
53
|
+
botLineBlk.bottom >= viewportBottom
|
|
54
|
+
? visBotLineDoc
|
|
55
|
+
: visBotLineDoc + Math.floor((viewportBottom - botLineBlk.bottom) / view.defaultLineHeight);
|
|
56
|
+
|
|
57
|
+
// const needsScrollTop = mainLine <= visTopLine + cursorLineMargin;
|
|
58
|
+
const needsScrollTop = false;
|
|
59
|
+
const needsScrollBot = mainLine >= visBotLine - cursorLineMargin;
|
|
60
|
+
|
|
61
|
+
// the scroll margins are overlapping
|
|
62
|
+
if (needsScrollTop && needsScrollBot) {
|
|
63
|
+
console.warn('is cursorScrollMargin too large for the editor size?');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// no need to scroll, we're in between scroll regions
|
|
68
|
+
if (!needsScrollTop && !needsScrollBot) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// if we're within the margin, but moving the other way, then don't scroll
|
|
73
|
+
if (selectionSet) {
|
|
74
|
+
const { head } = startState.selection.main;
|
|
75
|
+
const oldMainLine = lineAtPos(startState, head);
|
|
76
|
+
if (needsScrollTop && oldMainLine < mainLine) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (needsScrollBot && oldMainLine > mainLine) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
view.dispatch({
|
|
85
|
+
annotations: Transaction.userEvent.of(annotation),
|
|
86
|
+
effects: EditorView.scrollIntoView(s.selection.main.head, {
|
|
87
|
+
y: needsScrollTop ? 'start' : 'end',
|
|
88
|
+
yMargin: view.defaultLineHeight * cursorLineMargin,
|
|
89
|
+
}),
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
);
|
package/src/extensions/index.ts
CHANGED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { syntaxTree } from '@codemirror/language';
|
|
6
|
+
import { RangeSetBuilder, type EditorState } from '@codemirror/state';
|
|
7
|
+
import { EditorView, Decoration, type DecorationSet, WidgetType, ViewPlugin, type ViewUpdate } from '@codemirror/view';
|
|
8
|
+
|
|
9
|
+
import { mx } from '@dxos/react-ui-theme';
|
|
10
|
+
|
|
11
|
+
import { getToken } from '../../styles';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Widget for first/last line of code block (read-only).
|
|
15
|
+
*/
|
|
16
|
+
// TODO(burdon): Add copy-to-clipboard button.
|
|
17
|
+
class FencedCodeBoundary extends WidgetType {
|
|
18
|
+
constructor(private readonly _className: string) {
|
|
19
|
+
super();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
override toDOM() {
|
|
23
|
+
const el = document.createElement('span');
|
|
24
|
+
el.innerHTML = ' ';
|
|
25
|
+
el.className = mx('cm-code cm-codeblock', this._className);
|
|
26
|
+
return el;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class HorizontalRuleWidget extends WidgetType {
|
|
31
|
+
override toDOM() {
|
|
32
|
+
const el = document.createElement('span');
|
|
33
|
+
el.className = 'cm-hr';
|
|
34
|
+
return el;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
class LinkButton extends WidgetType {
|
|
39
|
+
constructor(
|
|
40
|
+
private readonly url: string,
|
|
41
|
+
private readonly render: (el: Element, url: string) => void,
|
|
42
|
+
) {
|
|
43
|
+
super();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
override eq(other: this) {
|
|
47
|
+
return this.url === other.url;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
override toDOM(view: EditorView) {
|
|
51
|
+
const el = document.createElement('span');
|
|
52
|
+
this.render(el, this.url);
|
|
53
|
+
return el;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
class CheckboxWidget extends WidgetType {
|
|
58
|
+
constructor(private _checked: boolean) {
|
|
59
|
+
super();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
override eq(other: this) {
|
|
63
|
+
return this._checked === other._checked;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
override toDOM(view: EditorView) {
|
|
67
|
+
const input = document.createElement('input');
|
|
68
|
+
input.className = 'cm-task-checkbox';
|
|
69
|
+
input.type = 'checkbox';
|
|
70
|
+
input.checked = this._checked;
|
|
71
|
+
if (view.state.readOnly) {
|
|
72
|
+
input.setAttribute('disabled', 'true');
|
|
73
|
+
} else {
|
|
74
|
+
input.onmousedown = (event: Event) => {
|
|
75
|
+
const pos = view.posAtDOM(input);
|
|
76
|
+
const text = view.state.sliceDoc(pos, pos + 3);
|
|
77
|
+
if (text === (this._checked ? '[x]' : '[ ]')) {
|
|
78
|
+
view.dispatch({
|
|
79
|
+
changes: { from: pos + 1, to: pos + 2, insert: this._checked ? ' ' : 'x' },
|
|
80
|
+
});
|
|
81
|
+
event.preventDefault();
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return input;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
override ignoreEvent() {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const hide = Decoration.replace({});
|
|
94
|
+
const fencedCodeTop = Decoration.replace({ widget: new FencedCodeBoundary('cm-codeblock-end cm-codeblock-first') });
|
|
95
|
+
const fencedCodeBottom = Decoration.replace({ widget: new FencedCodeBoundary('cm-codeblock-end cm-codeblock-last') });
|
|
96
|
+
const horizontalRule = Decoration.replace({ widget: new HorizontalRuleWidget() });
|
|
97
|
+
const checkedTask = Decoration.replace({ widget: new CheckboxWidget(true) });
|
|
98
|
+
const uncheckedTask = Decoration.replace({ widget: new CheckboxWidget(false) });
|
|
99
|
+
|
|
100
|
+
const editingRange = (state: EditorState, range: { from: number; to: number }) => {
|
|
101
|
+
const {
|
|
102
|
+
readOnly,
|
|
103
|
+
selection: {
|
|
104
|
+
main: { head },
|
|
105
|
+
},
|
|
106
|
+
} = state;
|
|
107
|
+
return !readOnly && head >= range.from && head <= range.to;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const MarksByParent = new Set(['CodeMark', 'EmphasisMark', 'StrikethroughMark', 'SubscriptMark', 'SuperscriptMark']);
|
|
111
|
+
|
|
112
|
+
const buildDecorations = (view: EditorView, options: DecorateOptions): DecorationSet => {
|
|
113
|
+
const builder = new RangeSetBuilder<Decoration>();
|
|
114
|
+
const { state } = view;
|
|
115
|
+
|
|
116
|
+
for (const { from, to } of view.visibleRanges) {
|
|
117
|
+
syntaxTree(state).iterate({
|
|
118
|
+
from,
|
|
119
|
+
to,
|
|
120
|
+
enter: (node) => {
|
|
121
|
+
if (node.name === 'FencedCode') {
|
|
122
|
+
const editing = editingRange(state, node);
|
|
123
|
+
// FencedCode > CodeMark > [CodeInfo] > CodeText > CodeMark
|
|
124
|
+
for (const block of view.viewportLineBlocks) {
|
|
125
|
+
if (block.to < node.from) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (block.from > node.to) {
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
const first = block.from <= node.from;
|
|
132
|
+
const last = block.to >= node.to;
|
|
133
|
+
if (!editing && (first || last)) {
|
|
134
|
+
builder.add(block.from, block.to, first ? fencedCodeTop : fencedCodeBottom);
|
|
135
|
+
} else {
|
|
136
|
+
builder.add(
|
|
137
|
+
block.from,
|
|
138
|
+
block.from,
|
|
139
|
+
Decoration.line({
|
|
140
|
+
class: mx('cm-code cm-codeblock-line', first && 'cm-codeblock-first', last && 'cm-codeblock-last'),
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return false;
|
|
146
|
+
} else if (node.name === 'Link') {
|
|
147
|
+
const marks = node.node.getChildren('LinkMark');
|
|
148
|
+
const urlNode = node.node.getChild('URL');
|
|
149
|
+
const editing = editingRange(state, node);
|
|
150
|
+
if (urlNode && marks.length >= 2) {
|
|
151
|
+
const url = state.sliceDoc(urlNode.from, urlNode.to);
|
|
152
|
+
if (!editing) {
|
|
153
|
+
builder.add(node.from, marks[0].to, hide);
|
|
154
|
+
}
|
|
155
|
+
builder.add(
|
|
156
|
+
marks[0].to,
|
|
157
|
+
marks[1].from,
|
|
158
|
+
Decoration.mark({
|
|
159
|
+
tagName: 'a',
|
|
160
|
+
attributes: {
|
|
161
|
+
class: 'cm-link',
|
|
162
|
+
href: url,
|
|
163
|
+
rel: 'noreferrer',
|
|
164
|
+
target: '_blank',
|
|
165
|
+
},
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
if (!editing) {
|
|
169
|
+
builder.add(
|
|
170
|
+
marks[1].from,
|
|
171
|
+
node.to,
|
|
172
|
+
options.renderLinkButton
|
|
173
|
+
? Decoration.replace({
|
|
174
|
+
widget: new LinkButton(url, options.renderLinkButton),
|
|
175
|
+
})
|
|
176
|
+
: hide,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
} else if (node.name === 'HeaderMark') {
|
|
181
|
+
const parent = node.node.parent!;
|
|
182
|
+
if (/^ATX/.test(parent.name) && !editingRange(state, state.doc.lineAt(node.from))) {
|
|
183
|
+
const next = state.doc.sliceString(node.to, node.to + 1);
|
|
184
|
+
builder.add(node.from, node.to + (next === ' ' ? 1 : 0), hide);
|
|
185
|
+
}
|
|
186
|
+
} else if (node.name === 'HorizontalRule') {
|
|
187
|
+
if (!editingRange(state, node)) {
|
|
188
|
+
builder.add(node.from, node.to, horizontalRule);
|
|
189
|
+
}
|
|
190
|
+
} else if (node.name === 'TaskMarker') {
|
|
191
|
+
// Check if cursor is inside text.
|
|
192
|
+
if (!editingRange(state, node)) {
|
|
193
|
+
const checked = state.doc.sliceString(node.from + 1, node.to - 1) === 'x';
|
|
194
|
+
builder.add(node.from - 2, node.from - 1, Decoration.mark({ class: 'cm-task' }));
|
|
195
|
+
builder.add(node.from, node.to, checked ? checkedTask : uncheckedTask);
|
|
196
|
+
}
|
|
197
|
+
} else if (MarksByParent.has(node.name)) {
|
|
198
|
+
if (!editingRange(state, node.node.parent!)) {
|
|
199
|
+
builder.add(node.from, node.to, hide);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return builder.finish();
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
export interface DecorateOptions {
|
|
210
|
+
renderLinkButton?: (el: Element, url: string) => void;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export const decorateMarkdown = (options: DecorateOptions = {}) => {
|
|
214
|
+
return [
|
|
215
|
+
ViewPlugin.fromClass(
|
|
216
|
+
class {
|
|
217
|
+
decorations: DecorationSet;
|
|
218
|
+
constructor(view: EditorView) {
|
|
219
|
+
this.decorations = buildDecorations(view, options);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
update(update: ViewUpdate) {
|
|
223
|
+
if (update.docChanged || update.selectionSet || update.viewportChanged) {
|
|
224
|
+
this.decorations = buildDecorations(update.view, options);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
decorations: (value) => value.decorations,
|
|
230
|
+
},
|
|
231
|
+
),
|
|
232
|
+
formattingStyles,
|
|
233
|
+
];
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const formattingStyles = EditorView.baseTheme({
|
|
237
|
+
'& .cm-code': {
|
|
238
|
+
fontFamily: getToken('fontFamily.mono', []).join(','),
|
|
239
|
+
},
|
|
240
|
+
'& .cm-codeblock-line': {
|
|
241
|
+
paddingInline: '1rem !important',
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
'& .cm-codeblock-end': {
|
|
245
|
+
display: 'inline-block',
|
|
246
|
+
width: '100%',
|
|
247
|
+
position: 'relative',
|
|
248
|
+
'&::after': {
|
|
249
|
+
position: 'absolute',
|
|
250
|
+
inset: 0,
|
|
251
|
+
content: '""',
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
'& .cm-codeblock-first': {
|
|
255
|
+
'&::after': {
|
|
256
|
+
borderTopLeftRadius: '.5rem',
|
|
257
|
+
borderTopRightRadius: '.5rem',
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
'& .cm-codeblock-last': {
|
|
261
|
+
'&::after': {
|
|
262
|
+
borderBottomLeftRadius: '.5rem',
|
|
263
|
+
borderBottomRightRadius: '.5rem',
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
'&light .cm-codeblock-line, &light .cm-activeLine.cm-codeblock-line': {
|
|
268
|
+
background: getToken('extend.semanticColors.input.light'),
|
|
269
|
+
mixBlendMode: 'darken',
|
|
270
|
+
},
|
|
271
|
+
'&dark .cm-codeblock-line, &dark .cm-activeLine.cm-codeblock-line': {
|
|
272
|
+
background: getToken('extend.semanticColors.input.dark'),
|
|
273
|
+
mixBlendMode: 'lighten',
|
|
274
|
+
},
|
|
275
|
+
'&light .cm-codeblock-first, &light .cm-codeblock-last': {
|
|
276
|
+
mixBlendMode: 'darken',
|
|
277
|
+
'&::after': {
|
|
278
|
+
background: getToken('extend.semanticColors.input.light'),
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
'&dark .cm-codeblock-first, &dark .cm-codeblock-last': {
|
|
282
|
+
mixBlendMode: 'lighten',
|
|
283
|
+
'&::after': {
|
|
284
|
+
background: getToken('extend.semanticColors.input.dark'),
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
'& .cm-hr': {
|
|
288
|
+
display: 'inline-block',
|
|
289
|
+
width: '100%',
|
|
290
|
+
height: '0',
|
|
291
|
+
verticalAlign: 'middle',
|
|
292
|
+
borderTop: `1px solid ${getToken('extend.colors.neutral.200')}`,
|
|
293
|
+
},
|
|
294
|
+
'& .cm-task': {
|
|
295
|
+
color: getToken('extend.colors.blue.500'),
|
|
296
|
+
},
|
|
297
|
+
'& .cm-task-checkbox': {
|
|
298
|
+
marginLeft: '4px',
|
|
299
|
+
marginRight: '4px',
|
|
300
|
+
},
|
|
301
|
+
});
|